Compare commits
26 Commits
feature/re
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e40b0c0f63 | ||
|
|
79fd223416 | ||
|
|
eaca3fe44a | ||
|
|
e033caacf6 | ||
|
|
d26ce0e810 | ||
|
|
7954111d05 | ||
|
|
f53905c002 | ||
|
|
90775cac7c | ||
|
|
54188eb1c0 | ||
|
|
bf208e3075 | ||
|
|
a5fb5ff484 | ||
|
|
25190f3154 | ||
|
|
437bdbd81f | ||
|
|
d8ab3839c6 | ||
|
|
13fe6c5f3d | ||
|
|
9fb904d68a | ||
|
|
b95cd79239 | ||
|
|
7a4a22f8e9 | ||
|
|
df6ab8774d | ||
|
|
feeb56ece8 | ||
|
|
1b6f5b6e34 | ||
|
|
1f2379db59 | ||
|
|
ef5153ce54 | ||
|
|
8faf4d26d0 | ||
|
|
1fd2689296 | ||
|
|
01c87ab82f |
3
.gitignore
vendored
@@ -14,4 +14,5 @@ node_modules
|
||||
.idea
|
||||
tsconfig.tsbuildinfo
|
||||
docker-compose.test.yml
|
||||
/data
|
||||
/data
|
||||
claude.md
|
||||
37
CONTRIBUTING.md
Normal 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
|
||||
239
README.md
@@ -9,10 +9,10 @@
|
||||
- [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)
|
||||
@@ -27,7 +27,7 @@
|
||||
## 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.
|
||||
@@ -68,8 +68,8 @@ 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>
|
||||
@@ -108,13 +108,13 @@ services:
|
||||
init: true
|
||||
```
|
||||
|
||||
**📖 For all available configuration options, see [`howto/DOCKER.md`](howto/DOCKER.md)**
|
||||
📖 **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 checklists and notes. This is perfect for integrations.
|
||||
`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)**
|
||||
|
||||
@@ -126,6 +126,14 @@ services:
|
||||
|
||||
📖 **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:
|
||||
@@ -220,82 +228,11 @@ Cr\*nMaster supports SSO via OIDC (OpenID Connect), compatible with providers li
|
||||
- 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)
|
||||
|
||||
<a id="rest-api"></a>
|
||||
|
||||
## 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 **[README_API.md](README_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
|
||||
**For detailed setup instructions, see **[howto/SSO.md](howto/SSO.md)**
|
||||
|
||||
<a id="usage"></a>
|
||||
|
||||
@@ -328,125 +265,7 @@ The application automatically detects your operating system and displays:
|
||||
|
||||
### 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
|
||||
📖 **For complete logging documentation, see [howto/LOGS.md](howto/LOGS.md)**
|
||||
|
||||
### Cron Schedule Format
|
||||
|
||||
@@ -468,27 +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.
|
||||
|
||||
<a id="technologies-used"></a>
|
||||
|
||||
## 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
|
||||
|
||||
<a id="contributing"></a>
|
||||
|
||||
## 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!
|
||||
@@ -529,6 +327,11 @@ I would like to thank the following members for raising issues and help test/deb
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,22 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/GlobalComponents/Cards/Card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/app/_components/GlobalComponents/Cards/Card";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { Clock, Plus } from "lucide-react";
|
||||
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 } from "react";
|
||||
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[];
|
||||
@@ -27,6 +54,47 @@ 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) => {
|
||||
@@ -38,6 +106,79 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
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,
|
||||
@@ -86,6 +227,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
handleEdit,
|
||||
handleEditSubmitLocal,
|
||||
handleNewCronSubmitLocal,
|
||||
handleBackupLocal,
|
||||
} = useCronJobState({ cronJobs, scripts });
|
||||
|
||||
return (
|
||||
@@ -102,28 +244,55 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
{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 })}
|
||||
{t("cronjobs.nOfNJObs", {
|
||||
filtered: filteredJobs.length,
|
||||
total: cronJobs.length,
|
||||
})}{" "}
|
||||
{selectedUser &&
|
||||
t("cronjobs.forUser", { user: selectedUser })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsNewCronModalOpen(true)}
|
||||
className="btn-primary glow-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("cronjobs.newTask")}
|
||||
</Button>
|
||||
<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">
|
||||
<UserFilter
|
||||
selectedUser={selectedUser}
|
||||
onUserChange={setSelectedUser}
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
<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 ? (
|
||||
@@ -132,26 +301,55 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
onNewTaskClick={() => setIsNewCronModalOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredJobs.map((job) => (
|
||||
<CronJobItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
errors={jobErrors[job.id] || []}
|
||||
runningJobId={runningJobId}
|
||||
deletingId={deletingId}
|
||||
onRun={handleRunLocal}
|
||||
onEdit={handleEdit}
|
||||
onClone={confirmClone}
|
||||
onResume={handleResumeLocal}
|
||||
onPause={handlePauseLocal}
|
||||
onToggleLogging={handleToggleLoggingLocal}
|
||||
onViewLogs={handleViewLogs}
|
||||
onDelete={confirmDelete}
|
||||
onErrorClick={handleErrorClickLocal}
|
||||
onErrorDismiss={refreshJobErrorsLocal}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
@@ -160,7 +358,6 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
<CronJobListModals
|
||||
cronJobs={cronJobs}
|
||||
scripts={scripts}
|
||||
|
||||
isNewCronModalOpen={isNewCronModalOpen}
|
||||
onNewCronModalClose={() => setIsNewCronModalOpen(false)}
|
||||
onNewCronSubmit={handleNewCronSubmitLocal}
|
||||
@@ -168,7 +365,6 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
onNewCronFormChange={(updates) =>
|
||||
setNewCronForm((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
isEditModalOpen={isEditModalOpen}
|
||||
onEditModalClose={() => setIsEditModalOpen(false)}
|
||||
onEditSubmit={handleEditSubmitLocal}
|
||||
@@ -176,20 +372,17 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
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);
|
||||
@@ -215,6 +408,26 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { DropdownMenu } from "@/app/_components/GlobalComponents/UIElements/DropdownMenu";
|
||||
import {
|
||||
Trash2,
|
||||
Edit,
|
||||
@@ -17,6 +18,9 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Download,
|
||||
Hash,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { CronJob } from "@/app/_utils/cronjob-utils";
|
||||
import { JobError } from "@/app/_utils/error-utils";
|
||||
@@ -28,12 +32,14 @@ import {
|
||||
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;
|
||||
@@ -42,6 +48,7 @@ interface CronJobItemProps {
|
||||
onDelete: (job: CronJob) => void;
|
||||
onToggleLogging: (id: string) => void;
|
||||
onViewLogs: (job: CronJob) => void;
|
||||
onBackup: (id: string) => void;
|
||||
onErrorClick: (error: JobError) => void;
|
||||
onErrorDismiss: () => void;
|
||||
}
|
||||
@@ -51,6 +58,7 @@ export const CronJobItem = ({
|
||||
errors,
|
||||
runningJobId,
|
||||
deletingId,
|
||||
scheduleDisplayMode,
|
||||
onRun,
|
||||
onEdit,
|
||||
onClone,
|
||||
@@ -59,14 +67,18 @@ export const CronJobItem = ({
|
||||
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) {
|
||||
@@ -76,42 +88,152 @@ export const CronJobItem = ({
|
||||
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"
|
||||
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 order-2 lg:order-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<code className="text-sm bg-purple-500/10 text-purple-600 dark:text-purple-400 px-2 py-1 rounded font-mono border border-purple-500/20">
|
||||
{job.schedule}
|
||||
</code>
|
||||
{(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">
|
||||
<pre
|
||||
className="text-sm font-medium text-foreground truncate bg-muted/30 px-2 py-1 rounded border border-border/30"
|
||||
title={displayCommand}
|
||||
>
|
||||
{displayCommand}
|
||||
</pre>
|
||||
<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>
|
||||
|
||||
{cronExplanation?.isValid && (
|
||||
<div className="flex items-start gap-1.5 mb-1">
|
||||
<Info className="h-3 w-3 text-primary mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{cronExplanation.humanReadable}
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{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")}
|
||||
@@ -134,31 +256,40 @@ export const CronJobItem = ({
|
||||
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>
|
||||
<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 && (
|
||||
<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 &&
|
||||
!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
|
||||
@@ -168,128 +299,81 @@ export const CronJobItem = ({
|
||||
/>
|
||||
)}
|
||||
</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={() => 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={() => onEdit(job)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title={t("cronjobs.editCronJob")}
|
||||
aria-label={t("cronjobs.editCronJob")}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onClone(job)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title={t("cronjobs.cloneCronJob")}
|
||||
aria-label={t("cronjobs.cloneCronJob")}
|
||||
>
|
||||
<Files className="h-3 w-3" />
|
||||
</Button>
|
||||
{job.paused ? (
|
||||
<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={() => onResume(job.id)}
|
||||
onClick={() => onRun(job.id)}
|
||||
disabled={runningJobId === job.id || job.paused}
|
||||
className="btn-outline h-8 px-3"
|
||||
title={t("cronjobs.resumeCronJob")}
|
||||
aria-label={t("cronjobs.resumeCronJob")}
|
||||
title={t("cronjobs.runCronManually")}
|
||||
aria-label={t("cronjobs.runCronManually")}
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
{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={() => onPause(job.id)}
|
||||
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")}
|
||||
>
|
||||
<Pause className="h-3 w-3" />
|
||||
{job.paused ? (
|
||||
<Play className="h-3 w-3" />
|
||||
) : (
|
||||
<Pause className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onToggleLogging(job.id)}
|
||||
className={`h-8 px-3 ${job.logsEnabled
|
||||
? "btn-outline border-blue-500/50 text-blue-600 dark:text-blue-400 hover:bg-blue-500/10"
|
||||
: "btn-outline"
|
||||
}`}
|
||||
title={
|
||||
job.logsEnabled
|
||||
? t("cronjobs.disableLogging")
|
||||
: t("cronjobs.enableLogging")
|
||||
}
|
||||
aria-label={
|
||||
job.logsEnabled
|
||||
? t("cronjobs.disableLogging")
|
||||
: t("cronjobs.enableLogging")
|
||||
}
|
||||
>
|
||||
{job.logsEnabled ? (
|
||||
<FileOutput className="h-3 w-3" />
|
||||
) : (
|
||||
<FileX className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
{job.logsEnabled && (
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewLogs(job)}
|
||||
onClick={() => {
|
||||
if (job.logsEnabled) {
|
||||
onViewLogs(job);
|
||||
} else {
|
||||
onToggleLogging(job.id);
|
||||
}
|
||||
}}
|
||||
className="btn-outline h-8 px-3"
|
||||
title={t("cronjobs.viewLogs")}
|
||||
aria-label={t("cronjobs.viewLogs")}
|
||||
title={
|
||||
job.logsEnabled
|
||||
? t("cronjobs.viewLogs")
|
||||
: t("cronjobs.enableLogging")
|
||||
}
|
||||
aria-label={
|
||||
job.logsEnabled
|
||||
? t("cronjobs.viewLogs")
|
||||
: t("cronjobs.enableLogging")
|
||||
}
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
{job.logsEnabled ? (
|
||||
<FileText className="h-3 w-3" />
|
||||
) : (
|
||||
<FileOutput className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(job)}
|
||||
disabled={deletingId === job.id}
|
||||
className="btn-destructive h-8 px-3"
|
||||
title={t("cronjobs.deleteCronJob")}
|
||||
aria-label={t("cronjobs.deleteCronJob")}
|
||||
>
|
||||
{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>
|
||||
|
||||
<DropdownMenu
|
||||
items={dropdownMenuItems}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resumeCronJobAction,
|
||||
runCronJob,
|
||||
toggleCronJobLogging,
|
||||
backupCronJob,
|
||||
} from "@/app/_server/actions/cronjobs";
|
||||
import { CronJob } from "@/app/_utils/cronjob-utils";
|
||||
|
||||
@@ -68,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,
|
||||
@@ -76,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();
|
||||
@@ -106,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(
|
||||
@@ -157,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 {
|
||||
@@ -170,9 +183,16 @@ export const handlePause = async (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const handleToggleLogging = async (id: string) => {
|
||||
export const handleToggleLogging = async (job: any) => {
|
||||
try {
|
||||
const result = await toggleCronJobLogging(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 {
|
||||
@@ -184,9 +204,15 @@ export const handleToggleLogging = async (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const handleResume = async (id: string) => {
|
||||
export const handleResume = async (job: any) => {
|
||||
try {
|
||||
const result = await resumeCronJobAction(id);
|
||||
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 {
|
||||
@@ -399,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,10 +28,11 @@ export const TabbedInterface = ({
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab("cronjobs")}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${activeTab === "cronjobs"
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${
|
||||
activeTab === "cronjobs"
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{t("cronjobs.cronJobs")}
|
||||
@@ -41,10 +42,11 @@ export const TabbedInterface = ({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("scripts")}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${activeTab === "scripts"
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${
|
||||
activeTab === "scripts"
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{t("scripts.scripts")}
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -1,23 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 } from "lucide-react";
|
||||
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 }: LoginFormProps) => {
|
||||
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();
|
||||
@@ -38,10 +69,10 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
if (result.success) {
|
||||
router.push("/");
|
||||
} else {
|
||||
setError(result.message || "Login failed");
|
||||
setError(result.message || t("login.loginFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred. Please try again.");
|
||||
setError(t("login.genericError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -52,23 +83,55 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
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>Welcome to Cr*nMaster</CardTitle>
|
||||
<CardTitle>{t("login.welcomeTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{hasPassword && hasOIDC
|
||||
? "Sign in with password or SSO"
|
||||
? t("login.signInWithPasswordOrSSO")
|
||||
: hasOIDC
|
||||
? "Sign in with SSO"
|
||||
: "Enter your password to continue"}
|
||||
? 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">
|
||||
@@ -77,7 +140,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
placeholder={t("login.enterPassword")}
|
||||
className="pr-10"
|
||||
required
|
||||
disabled={isLoading}
|
||||
@@ -101,7 +164,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
className="w-full"
|
||||
disabled={isLoading || !password.trim()}
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
{isLoading ? t("login.signingIn") : t("login.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
@@ -113,7 +176,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
{t("login.orContinueWith")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +191,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
{isLoading ? "Redirecting..." : "Sign in with SSO"}
|
||||
{isLoading ? t("login.redirecting") : t("login.signInWithSSO")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -138,6 +201,14 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -5,39 +5,38 @@ 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 [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -123,11 +124,10 @@ export const CreateTaskModal = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomCommand}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
!form.selectedScriptId
|
||||
className={`p-4 rounded-lg border-2 transition-all ${!form.selectedScriptId
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="h-5 w-5" />
|
||||
@@ -145,11 +145,10 @@ export const CreateTaskModal = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSelectScriptModalOpen(true)}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
form.selectedScriptId
|
||||
className={`p-4 rounded-lg border-2 transition-all ${form.selectedScriptId
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
158
app/_components/FeatureComponents/Modals/FiltersModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
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;
|
||||
@@ -14,6 +17,9 @@ interface LiveLogModalProps {
|
||||
jobComment?: string;
|
||||
}
|
||||
|
||||
const MAX_LINES_FULL_RENDER = 10000;
|
||||
const TAIL_LINES = 5000;
|
||||
|
||||
export const LiveLogModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -21,40 +27,136 @@ export const LiveLogModal = ({
|
||||
jobId,
|
||||
jobComment,
|
||||
}: LiveLogModalProps) => {
|
||||
const t = useTranslations();
|
||||
const [logContent, setLogContent] = useState<string>("");
|
||||
const [status, setStatus] = useState<"running" | "completed" | "failed">("running");
|
||||
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 || !runId) return;
|
||||
if (isOpen) {
|
||||
lastOffsetRef.current = 0;
|
||||
setLogContent("");
|
||||
setTailMode(false);
|
||||
setShowSizeWarning(false);
|
||||
setFileSize(0);
|
||||
setLineCount(0);
|
||||
setShowFullLog(false);
|
||||
setIsJobComplete(false);
|
||||
}
|
||||
}, [isOpen, runId]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/logs/stream?runId=${runId}`);
|
||||
const data = await response.json();
|
||||
useEffect(() => {
|
||||
if (isOpen && runId && !isJobComplete) {
|
||||
lastOffsetRef.current = 0;
|
||||
setLogContent("");
|
||||
fetchLogs();
|
||||
}
|
||||
}, [maxLines]);
|
||||
|
||||
if (data.content) {
|
||||
setLogContent(data.content);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(data.status || "running");
|
||||
if (data.totalLines !== undefined) {
|
||||
setTotalLines(data.totalLines);
|
||||
}
|
||||
setLineCount(data.displayedLines || 0);
|
||||
|
||||
if (data.exitCode !== undefined) {
|
||||
setExitCode(data.exitCode);
|
||||
if (data.truncated !== undefined) {
|
||||
setTruncated(data.truncated);
|
||||
}
|
||||
|
||||
if (lastOffsetRef.current === 0 && data.content) {
|
||||
setLogContent(data.content);
|
||||
|
||||
if (data.truncated) {
|
||||
setTailMode(true);
|
||||
}
|
||||
} catch (error) {
|
||||
} 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();
|
||||
|
||||
const interval = setInterval(fetchLogs, 2000);
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
if (isPageVisible && !isJobComplete) {
|
||||
interval = setInterval(fetchLogs, 3000);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen, runId]);
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [isOpen, runId, isPageVisible, fetchLogs, isJobComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -64,53 +166,83 @@ export const LiveLogModal = ({
|
||||
setStatus("completed");
|
||||
setExitCode(event.data.exitCode);
|
||||
|
||||
fetch(`/api/logs/stream?runId=${runId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
fetch(`/api/logs/stream?runId=${runId}&offset=0`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.content) {
|
||||
setLogContent(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}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
fetch(`/api/logs/stream?runId=${runId}&offset=0`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.content) {
|
||||
setLogContent(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]);
|
||||
}, [isOpen, runId, subscribe, tailMode]);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
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>Live Job Execution{jobComment && `: ${jobComment}`}</span>
|
||||
<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" />
|
||||
Running...
|
||||
{t("cronjobs.running")}
|
||||
</span>
|
||||
)}
|
||||
{status === "completed" && (
|
||||
<span className="flex items-center gap-1 text-sm text-green-500">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Completed (Exit: {exitCode})
|
||||
{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" />
|
||||
Failed (Exit: {exitCode})
|
||||
{t("cronjobs.jobFailed", { exitCode: exitCode ?? 1 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -125,15 +257,108 @@ export const LiveLogModal = ({
|
||||
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 || "Waiting for job to start...\n\nLogs will appear here in real-time."}
|
||||
{logContent || t("cronjobs.waitingForJobToStart")}
|
||||
<div ref={logEndRef} />
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Run ID: {runId} | Job ID: {jobId}
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("cronjobs.runIdJobId", { runId, jobId })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
218
app/_components/FeatureComponents/Modals/RestoreBackupModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,8 @@ 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 } from "lucide-react";
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"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 "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { Terminal, Copy, Check } from "lucide-react";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
@@ -32,6 +32,8 @@ interface ScriptsManagerProps {
|
||||
scripts: Script[];
|
||||
}
|
||||
|
||||
const DRAFT_STORAGE_KEY = "cronjob_script_draft";
|
||||
|
||||
export const ScriptsManager = ({
|
||||
scripts: initialScripts,
|
||||
}: ScriptsManagerProps) => {
|
||||
@@ -46,11 +48,13 @@ export const ScriptsManager = ({
|
||||
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: "",
|
||||
@@ -58,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");
|
||||
@@ -78,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);
|
||||
@@ -318,6 +355,8 @@ export const ScriptsManager = ({
|
||||
onFormChange={(updates) =>
|
||||
setCreateForm((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
isDraft={isDraft}
|
||||
onClearDraft={handleClearDraft}
|
||||
/>
|
||||
|
||||
<EditScriptModal
|
||||
|
||||
@@ -4,13 +4,7 @@ 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";
|
||||
import { Clock, HardDrive, Cpu, Monitor, Wifi } from "lucide-react";
|
||||
|
||||
interface SystemInfoType {
|
||||
hostname: string;
|
||||
@@ -54,10 +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;
|
||||
@@ -70,28 +65,53 @@ 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") {
|
||||
if (event.type === "system-stats" && event.data !== null) {
|
||||
setSystemInfo(event.data);
|
||||
}
|
||||
});
|
||||
@@ -105,30 +125,42 @@ export const SystemInfoCard = ({
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -176,14 +208,18 @@ export const SystemInfoCard = ({
|
||||
status: systemInfo.gpu.status,
|
||||
color: "text-indigo-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",
|
||||
}] : []),
|
||||
...(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 = [
|
||||
@@ -197,18 +233,19 @@ export const SystemInfoCard = ({
|
||||
value: `${systemInfo.memory.usage}%`,
|
||||
status: systemInfo.memory.status,
|
||||
},
|
||||
...(systemInfo.network ? [{
|
||||
label: t("sidebar.networkLatency"),
|
||||
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
|
||||
defaultCollapsed={false}
|
||||
quickStats={quickStats}
|
||||
>
|
||||
<Sidebar defaultCollapsed={false} quickStats={quickStats}>
|
||||
<SystemStatus
|
||||
status={systemInfo.systemStatus.overall}
|
||||
details={systemInfo.systemStatus.details}
|
||||
@@ -271,4 +308,4 @@ export const SystemInfoCard = ({
|
||||
</div>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -50,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 ? `${t("common.userWithUsername", { user: selectedUser })}` : t("common.allUsers")}
|
||||
</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">
|
||||
@@ -84,8 +85,9 @@ 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" : ""
|
||||
}`}
|
||||
>
|
||||
{t("common.allUsers")}
|
||||
</button>
|
||||
@@ -96,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>
|
||||
@@ -106,4 +109,4 @@ export const UserFilter = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
130
app/_components/GlobalComponents/UIElements/DropdownMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -10,9 +10,10 @@ interface ModalProps {
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
35
app/_components/GlobalComponents/UIElements/Switch.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,32 @@
|
||||
export const WRITE_CRONTAB = (content: string, user: string) => `echo '${content}' | crontab -u ${user} -`;
|
||||
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_CRONTAB = (user: string) =>
|
||||
`crontab -l -u ${user} 2>/dev/null || echo ""`;
|
||||
|
||||
export const READ_CRON_FILE = () => 'crontab -l 2>/dev/null || echo ""'
|
||||
export const READ_CRON_FILE = () => 'crontab -l 2>/dev/null || echo ""';
|
||||
|
||||
export const WRITE_CRON_FILE = (content: string) => `echo "${content}" | crontab -`;
|
||||
export const WRITE_CRON_FILE = (content: string) => {
|
||||
return `crontab - << 'EOF'\n${content}\nEOF`;
|
||||
};
|
||||
|
||||
export const WRITE_HOST_CRONTAB = (base64Content: string, user: string) => `echo '${base64Content}' | base64 -d | crontab -u ${user} -`;
|
||||
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 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_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 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 ''`;
|
||||
export const READ_CRONTABS_DIRECTORY = `ls /var/spool/cron/crontabs/ 2>/dev/null || echo ''`;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export const Locales = [
|
||||
{ locale: "en", label: "English" },
|
||||
{ locale: "it", label: "Italian" },
|
||||
];
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
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;
|
||||
@@ -10,13 +17,22 @@ interface SSEContextType {
|
||||
|
||||
const SSEContext = createContext<SSEContextType | null>(null);
|
||||
|
||||
export const SSEProvider: React.FC<{ children: React.ReactNode, liveUpdatesEnabled: boolean }> = ({ children, liveUpdatesEnabled }) => {
|
||||
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) {
|
||||
if (!liveUpdatesEnabled || !isPageVisible) {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
setIsConnected(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,7 +46,14 @@ export const SSEProvider: React.FC<{ children: React.ReactNode, liveUpdatesEnabl
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
const eventTypes = ["job-started", "job-completed", "job-failed", "log-line", "system-stats", "heartbeat"];
|
||||
const eventTypes = [
|
||||
"job-started",
|
||||
"job-completed",
|
||||
"job-failed",
|
||||
"log-line",
|
||||
"system-stats",
|
||||
"heartbeat",
|
||||
];
|
||||
|
||||
eventTypes.forEach((eventType) => {
|
||||
eventSource.addEventListener(eventType, (event: MessageEvent) => {
|
||||
@@ -48,7 +71,7 @@ export const SSEProvider: React.FC<{ children: React.ReactNode, liveUpdatesEnabl
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, []);
|
||||
}, [liveUpdatesEnabled, isPageVisible]);
|
||||
|
||||
const subscribe = (callback: (event: SSEEvent) => void) => {
|
||||
subscribersRef.current.add(callback);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
handleEditSubmit,
|
||||
handleNewCronSubmit,
|
||||
handleToggleLogging,
|
||||
handleBackup,
|
||||
} from "@/app/_components/FeatureComponents/Cronjobs/helpers";
|
||||
|
||||
interface CronJobListProps {
|
||||
@@ -126,7 +127,10 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
};
|
||||
|
||||
const handleDeleteLocal = async (id: string) => {
|
||||
await handleDelete(id, getHelperState());
|
||||
const job = cronJobs.find(j => j.id === id);
|
||||
if (job) {
|
||||
await handleDelete(job, getHelperState());
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneLocal = async (newComment: string) => {
|
||||
@@ -134,11 +138,17 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
};
|
||||
|
||||
const handlePauseLocal = async (id: string) => {
|
||||
await handlePause(id);
|
||||
const job = cronJobs.find(j => j.id === id);
|
||||
if (job) {
|
||||
await handlePause(job);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeLocal = async (id: string) => {
|
||||
await handleResume(id);
|
||||
const job = cronJobs.find(j => j.id === id);
|
||||
if (job) {
|
||||
await handleResume(job);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunLocal = async (id: string) => {
|
||||
@@ -148,7 +158,10 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
};
|
||||
|
||||
const handleToggleLoggingLocal = async (id: string) => {
|
||||
await handleToggleLogging(id);
|
||||
const job = cronJobs.find(j => j.id === id);
|
||||
if (job) {
|
||||
await handleToggleLogging(job);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewLogs = (job: CronJob) => {
|
||||
@@ -185,6 +198,13 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
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,
|
||||
@@ -233,5 +253,6 @@ export const useCronJobState = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
handleEdit,
|
||||
handleEditSubmitLocal,
|
||||
handleNewCronSubmitLocal,
|
||||
handleBackupLocal,
|
||||
};
|
||||
};
|
||||
24
app/_hooks/usePageVisibility.ts
Normal 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;
|
||||
}
|
||||
@@ -3,25 +3,27 @@
|
||||
import {
|
||||
getCronJobs,
|
||||
addCronJob,
|
||||
deleteCronJob,
|
||||
updateCronJob,
|
||||
pauseCronJob,
|
||||
resumeCronJob,
|
||||
cleanupCrontab,
|
||||
readUserCrontab,
|
||||
writeUserCrontab,
|
||||
findJobIndex,
|
||||
updateCronJob,
|
||||
type CronJob,
|
||||
} from "@/app/_utils/cronjob-utils";
|
||||
import { getAllTargetUsers, getUserInfo } from "@/app/_utils/crontab-utils";
|
||||
import { getAllTargetUsers } from "@/app/_utils/crontab-utils";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getScriptPathForCron } from "@/app/_server/actions/scripts";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { isDocker } from "@/app/_server/actions/global";
|
||||
import {
|
||||
runJobSynchronously,
|
||||
runJobInBackground,
|
||||
} from "@/app/_utils/job-execution-utils";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
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 {
|
||||
@@ -90,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" };
|
||||
@@ -124,8 +138,15 @@ export const editCronJob = async (
|
||||
return { success: false, message: "Missing required fields" };
|
||||
}
|
||||
|
||||
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(
|
||||
id,
|
||||
job,
|
||||
schedule,
|
||||
command,
|
||||
comment,
|
||||
@@ -152,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) {
|
||||
@@ -183,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" };
|
||||
@@ -204,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" };
|
||||
@@ -257,23 +302,16 @@ export const cleanupCrontabAction = async (): Promise<{
|
||||
};
|
||||
|
||||
export const toggleCronJobLogging = async (
|
||||
id: string
|
||||
jobData: { id: string; schedule: string; command: string; comment?: string; user: string; logsEnabled?: boolean }
|
||||
): Promise<{ success: boolean; message: string; details?: string }> => {
|
||||
try {
|
||||
const cronJobs = await getCronJobs();
|
||||
const job = cronJobs.find((j) => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
return { success: false, message: "Cron job not found" };
|
||||
}
|
||||
|
||||
const newLogsEnabled = !job.logsEnabled;
|
||||
const newLogsEnabled = !jobData.logsEnabled;
|
||||
|
||||
const success = await updateCronJob(
|
||||
id,
|
||||
job.schedule,
|
||||
job.command,
|
||||
job.comment || "",
|
||||
jobData,
|
||||
jobData.schedule,
|
||||
jobData.command,
|
||||
jobData.comment || "",
|
||||
newLogsEnabled
|
||||
);
|
||||
|
||||
@@ -309,7 +347,7 @@ export const runCronJob = async (
|
||||
mode?: "sync" | "async";
|
||||
}> => {
|
||||
try {
|
||||
const cronJobs = await getCronJobs();
|
||||
const cronJobs = await getCronJobs(false);
|
||||
const job = cronJobs.find((j) => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
@@ -356,7 +394,7 @@ export const executeJob = async (
|
||||
mode?: "sync" | "async";
|
||||
}> => {
|
||||
try {
|
||||
const cronJobs = await getCronJobs();
|
||||
const cronJobs = await getCronJobs(false);
|
||||
const job = cronJobs.find((j) => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
@@ -386,3 +424,193 @@ export const executeJob = async (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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";
|
||||
@@ -13,10 +13,6 @@ import { isDocker, getHostScriptsPath } from "@/app/_server/actions/global";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const getScriptPath = (filename: string): string => {
|
||||
return join(process.cwd(), SCRIPTS_DIR, filename);
|
||||
};
|
||||
|
||||
export const getScriptPathForCron = async (
|
||||
filename: string
|
||||
): Promise<string> => {
|
||||
@@ -25,19 +21,19 @@ export const getScriptPathForCron = async (
|
||||
if (docker) {
|
||||
const hostScriptsPath = await getHostScriptsPath();
|
||||
if (hostScriptsPath) {
|
||||
return `bash ${join(hostScriptsPath, filename)}`;
|
||||
return `bash ${path.join(hostScriptsPath, filename)}`;
|
||||
}
|
||||
console.warn("Could not determine host scripts path, using container path");
|
||||
}
|
||||
|
||||
return `bash ${join(process.cwd(), SCRIPTS_DIR, filename)}`;
|
||||
return `bash ${path.join(process.cwd(), SCRIPTS_DIR, filename)}`;
|
||||
};
|
||||
|
||||
export const getHostScriptPath = (filename: string): string => {
|
||||
return `bash ${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 = (content: string): string => {
|
||||
export const normalizeLineEndings = async (content: string): Promise<string> => {
|
||||
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
};
|
||||
|
||||
@@ -65,14 +61,14 @@ const generateUniqueFilename = async (baseName: string): Promise<string> => {
|
||||
};
|
||||
|
||||
const ensureScriptsDirectory = async () => {
|
||||
const scriptsDir = join(process.cwd(), SCRIPTS_DIR);
|
||||
const scriptsDir = path.join(process.cwd(), SCRIPTS_DIR);
|
||||
if (!existsSync(scriptsDir)) {
|
||||
await mkdir(scriptsDir, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
const ensureHostScriptsDirectory = async () => {
|
||||
const hostScriptsDir = join(process.cwd(), SCRIPTS_DIR);
|
||||
const hostScriptsDir = path.join(process.cwd(), SCRIPTS_DIR);
|
||||
if (!existsSync(hostScriptsDir)) {
|
||||
await mkdir(hostScriptsDir, { recursive: true });
|
||||
}
|
||||
@@ -81,7 +77,7 @@ const ensureHostScriptsDirectory = async () => {
|
||||
const saveScriptFile = async (filename: string, content: string) => {
|
||||
await ensureScriptsDirectory();
|
||||
|
||||
const scriptPath = getScriptPath(filename);
|
||||
const scriptPath = path.join(process.cwd(), SCRIPTS_DIR, filename);
|
||||
await writeFile(scriptPath, content, "utf8");
|
||||
|
||||
try {
|
||||
@@ -92,7 +88,7 @@ const saveScriptFile = async (filename: string, content: string) => {
|
||||
};
|
||||
|
||||
const deleteScriptFile = async (filename: string) => {
|
||||
const scriptPath = getScriptPath(filename);
|
||||
const scriptPath = path.join(process.cwd(), SCRIPTS_DIR, filename);
|
||||
if (existsSync(scriptPath)) {
|
||||
await unlink(scriptPath);
|
||||
}
|
||||
@@ -125,7 +121,7 @@ export const createScript = async (
|
||||
|
||||
`;
|
||||
|
||||
const normalizedContent = normalizeLineEndings(content);
|
||||
const normalizedContent = await normalizeLineEndings(content);
|
||||
const fullContent = metadataHeader + normalizedContent;
|
||||
|
||||
await saveScriptFile(filename, fullContent);
|
||||
@@ -176,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);
|
||||
@@ -235,7 +231,7 @@ export const cloneScript = async (
|
||||
|
||||
`;
|
||||
|
||||
const normalizedContent = normalizeLineEndings(originalContent);
|
||||
const normalizedContent = await normalizeLineEndings(originalContent);
|
||||
const fullContent = metadataHeader + normalizedContent;
|
||||
|
||||
await saveScriptFile(filename, fullContent);
|
||||
@@ -262,7 +258,7 @@ export const cloneScript = async (
|
||||
|
||||
export const getScriptContent = async (filename: string): Promise<string> => {
|
||||
try {
|
||||
const scriptPath = getScriptPath(filename);
|
||||
const scriptPath = path.join(process.cwd(), SCRIPTS_DIR, filename);
|
||||
|
||||
if (existsSync(scriptPath)) {
|
||||
const content = await readFile(scriptPath, "utf8");
|
||||
@@ -299,7 +295,7 @@ export const executeScript = async (
|
||||
}> => {
|
||||
try {
|
||||
await ensureHostScriptsDirectory();
|
||||
const hostScriptPath = getHostScriptPath(filename);
|
||||
const hostScriptPath = await getHostScriptPath(filename);
|
||||
|
||||
if (!existsSync(hostScriptPath)) {
|
||||
return {
|
||||
|
||||
59
app/_server/actions/translations/index.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
@@ -10,7 +10,8 @@
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading"
|
||||
"loading": "Loading",
|
||||
"version": "{version}"
|
||||
},
|
||||
"cronjobs": {
|
||||
"cronJobs": "Cron Jobs",
|
||||
@@ -55,7 +56,57 @@
|
||||
"loading": "Loading",
|
||||
"close": "Close",
|
||||
"healthy": "Healthy",
|
||||
"failed": "Failed (Exit: {exitCode})"
|
||||
"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",
|
||||
@@ -77,7 +128,11 @@
|
||||
"commandPreview": "Command Preview",
|
||||
"scriptContent": "Script Content",
|
||||
"selectScriptToPreview": "Select a script to preview",
|
||||
"searchScripts": "Search scripts..."
|
||||
"searchScripts": "Search scripts...",
|
||||
"draft": "Draft",
|
||||
"clearDraft": "Clear Draft",
|
||||
"close": "Close",
|
||||
"draftCleared": "Draft cleared"
|
||||
},
|
||||
"sidebar": {
|
||||
"systemOverview": "System Overview",
|
||||
@@ -112,5 +167,26 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
"change": "Modifica",
|
||||
"description": "Descrizione",
|
||||
"optional": "Opzionale",
|
||||
"cancel": "Annulla"
|
||||
"cancel": "Annulla",
|
||||
"refresh": "Aggiorna",
|
||||
"close": "Chiudi",
|
||||
"version": "{version}"
|
||||
},
|
||||
"cronjobs": {
|
||||
"cronJobs": "Operazioni Cron",
|
||||
@@ -52,7 +55,54 @@
|
||||
"loading": "Caricamento",
|
||||
"close": "Chiudi",
|
||||
"healthy": "Sano",
|
||||
"failed": "Fallito (Exit: {exitCode})"
|
||||
"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",
|
||||
@@ -74,7 +124,11 @@
|
||||
"commandPreview": "Anteprima Comando",
|
||||
"scriptContent": "Contenuto Script",
|
||||
"selectScriptToPreview": "Seleziona uno script per l'anteprima",
|
||||
"searchScripts": "Cerca script..."
|
||||
"searchScripts": "Cerca script...",
|
||||
"draft": "Bozza",
|
||||
"clearDraft": "Cancella Bozza",
|
||||
"close": "Chiudi",
|
||||
"draftCleared": "Bozza cancellata"
|
||||
},
|
||||
"sidebar": {
|
||||
"systemOverview": "Panoramica del Sistema",
|
||||
@@ -109,5 +163,26 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
@@ -56,16 +56,19 @@ export async function requireAuth(
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasValidApiKey = validateApiKey(request);
|
||||
if (hasValidApiKey) {
|
||||
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,
|
||||
hasApiKey: hasValidApiKey,
|
||||
apiKeyConfigured: !!process.env.API_KEY,
|
||||
hasAuthHeader: !!request.headers.get("authorization"),
|
||||
});
|
||||
}
|
||||
|
||||
189
app/_utils/backup-utils.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
unwrapCommand,
|
||||
isCommandWrapped,
|
||||
} from "@/app/_utils/wrapper-utils";
|
||||
import { generateShortUUID } from "@/app/_utils/uuid-utils";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -45,7 +46,7 @@ export interface CronJob {
|
||||
};
|
||||
}
|
||||
|
||||
const readUserCrontab = async (user: string): Promise<string> => {
|
||||
export const readUserCrontab = async (user: string): Promise<string> => {
|
||||
const docker = await isDocker();
|
||||
|
||||
if (docker) {
|
||||
@@ -58,7 +59,7 @@ const readUserCrontab = async (user: string): Promise<string> => {
|
||||
}
|
||||
};
|
||||
|
||||
const writeUserCrontab = async (
|
||||
export const writeUserCrontab = async (
|
||||
user: string,
|
||||
content: string
|
||||
): Promise<boolean> => {
|
||||
@@ -101,7 +102,9 @@ const getAllUsers = async (): Promise<{ user: string; content: string }[]> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getCronJobs = async (includeLogErrors: boolean = true): Promise<CronJob[]> => {
|
||||
export const getCronJobs = async (
|
||||
includeLogErrors: boolean = true
|
||||
): Promise<CronJob[]> => {
|
||||
try {
|
||||
const userCrontabs = await getAllUsers();
|
||||
let allJobs: CronJob[] = [];
|
||||
@@ -111,15 +114,16 @@ export const getCronJobs = async (includeLogErrors: boolean = true): Promise<Cro
|
||||
|
||||
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 jobIds = allJobs.map((job) => job.id);
|
||||
const errorMap = await getAllJobLogErrors(jobIds);
|
||||
|
||||
allJobs = allJobs.map(job => ({
|
||||
allJobs = allJobs.map((job) => ({
|
||||
...job,
|
||||
logError: errorMap.get(job.id),
|
||||
}));
|
||||
@@ -140,27 +144,31 @@ export const addCronJob = async (
|
||||
logsEnabled: boolean = false
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const jobId = generateShortUUID();
|
||||
|
||||
if (user) {
|
||||
const cronContent = await readUserCrontab(user);
|
||||
|
||||
const lines = cronContent.split("\n");
|
||||
const existingJobs = parseJobsFromLines(lines, user);
|
||||
const nextJobIndex = existingJobs.length;
|
||||
const jobId = `${user}-${nextJobIndex}`;
|
||||
|
||||
let finalCommand = command;
|
||||
if (logsEnabled && !isCommandWrapped(command)) {
|
||||
const docker = await isDocker();
|
||||
finalCommand = await wrapCommandWithLogger(jobId, command, docker, comment);
|
||||
finalCommand = await wrapCommandWithLogger(
|
||||
jobId,
|
||||
command,
|
||||
docker,
|
||||
comment
|
||||
);
|
||||
} else if (logsEnabled && isCommandWrapped(command)) {
|
||||
finalCommand = command;
|
||||
}
|
||||
|
||||
const formattedComment = formatCommentWithMetadata(comment, logsEnabled);
|
||||
const formattedComment = formatCommentWithMetadata(
|
||||
comment,
|
||||
logsEnabled,
|
||||
jobId
|
||||
);
|
||||
|
||||
const newEntry = formattedComment
|
||||
? `# ${formattedComment}\n${schedule} ${finalCommand}`
|
||||
: `${schedule} ${finalCommand}`;
|
||||
const newEntry = `# ${formattedComment}\n${schedule} ${finalCommand}`;
|
||||
|
||||
let newCron;
|
||||
if (cronContent.trim() === "") {
|
||||
@@ -174,25 +182,26 @@ export const addCronJob = async (
|
||||
} else {
|
||||
const cronContent = await readCronFiles();
|
||||
|
||||
const currentUser = process.env.USER || "user";
|
||||
const lines = cronContent.split("\n");
|
||||
const existingJobs = parseJobsFromLines(lines, currentUser);
|
||||
const nextJobIndex = existingJobs.length;
|
||||
const jobId = `${currentUser}-${nextJobIndex}`;
|
||||
|
||||
let finalCommand = command;
|
||||
if (logsEnabled && !isCommandWrapped(command)) {
|
||||
const docker = await isDocker();
|
||||
finalCommand = await wrapCommandWithLogger(jobId, command, docker, comment);
|
||||
finalCommand = await wrapCommandWithLogger(
|
||||
jobId,
|
||||
command,
|
||||
docker,
|
||||
comment
|
||||
);
|
||||
} else if (logsEnabled && isCommandWrapped(command)) {
|
||||
finalCommand = command;
|
||||
}
|
||||
|
||||
const formattedComment = formatCommentWithMetadata(comment, logsEnabled);
|
||||
const formattedComment = formatCommentWithMetadata(
|
||||
comment,
|
||||
logsEnabled,
|
||||
jobId
|
||||
);
|
||||
|
||||
const newEntry = formattedComment
|
||||
? `# ${formattedComment}\n${schedule} ${finalCommand}`
|
||||
: `${schedule} ${finalCommand}`;
|
||||
const newEntry = `# ${formattedComment}\n${schedule} ${finalCommand}`;
|
||||
|
||||
let newCron;
|
||||
if (cronContent.trim() === "") {
|
||||
@@ -212,11 +221,25 @@ export const addCronJob = async (
|
||||
|
||||
export const deleteCronJob = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
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"));
|
||||
|
||||
@@ -228,43 +251,54 @@ export const deleteCronJob = async (id: string): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const updateCronJob = async (
|
||||
id: string,
|
||||
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, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
|
||||
const user = jobData.user;
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const lines = cronContent.split("\n");
|
||||
const existingJobs = parseJobsFromLines(lines, user);
|
||||
const currentJob = existingJobs[jobIndex];
|
||||
|
||||
if (!currentJob) {
|
||||
console.error(`Job with index ${jobIndex} not found`);
|
||||
const jobIndex = findJobIndex(jobData, lines, user);
|
||||
|
||||
if (jobIndex === -1) {
|
||||
console.error(`Job not found in crontab`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isWrappd = isCommandWrapped(command);
|
||||
const isWrapped = isCommandWrapped(command);
|
||||
|
||||
let finalCommand = command;
|
||||
|
||||
if (logsEnabled && !isWrappd) {
|
||||
if (logsEnabled && !isWrapped) {
|
||||
const docker = await isDocker();
|
||||
finalCommand = await wrapCommandWithLogger(id, command, docker, comment);
|
||||
}
|
||||
else if (!logsEnabled && isWrappd) {
|
||||
finalCommand = await wrapCommandWithLogger(
|
||||
jobData.id,
|
||||
command,
|
||||
docker,
|
||||
comment
|
||||
);
|
||||
} else if (!logsEnabled && isWrapped) {
|
||||
finalCommand = unwrapCommand(command);
|
||||
}
|
||||
else if (logsEnabled && isWrappd) {
|
||||
} else if (logsEnabled && isWrapped) {
|
||||
const unwrapped = unwrapCommand(command);
|
||||
const docker = await isDocker();
|
||||
finalCommand = await wrapCommandWithLogger(id, unwrapped, docker, comment);
|
||||
}
|
||||
else {
|
||||
finalCommand = await wrapCommandWithLogger(
|
||||
jobData.id,
|
||||
unwrapped,
|
||||
docker,
|
||||
comment
|
||||
);
|
||||
} else {
|
||||
finalCommand = command;
|
||||
}
|
||||
|
||||
@@ -274,7 +308,8 @@ export const updateCronJob = async (
|
||||
schedule,
|
||||
finalCommand,
|
||||
comment,
|
||||
logsEnabled
|
||||
logsEnabled,
|
||||
jobData.id
|
||||
);
|
||||
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
|
||||
|
||||
@@ -287,12 +322,26 @@ export const updateCronJob = async (
|
||||
|
||||
export const pauseCronJob = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
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 newCronEntries = pauseJobInLines(lines, jobIndex);
|
||||
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);
|
||||
@@ -304,12 +353,26 @@ export const pauseCronJob = async (id: string): Promise<boolean> => {
|
||||
|
||||
export const resumeCronJob = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
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 newCronEntries = resumeJobInLines(lines, jobIndex);
|
||||
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);
|
||||
@@ -336,3 +399,31 @@ export const cleanupCrontab = async (): Promise<boolean> => {
|
||||
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 || "")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -37,7 +68,7 @@ export const setJobError = (error: JobError) => {
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(errors));
|
||||
} catch { }
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const removeJobError = (errorId: string) => {
|
||||
@@ -47,7 +78,7 @@ export const removeJobError = (errorId: string) => {
|
||||
const errors = getJobErrors();
|
||||
const filtered = errors.filter((e) => e.id !== errorId);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||
} catch { }
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const getJobErrorsByJobId = (jobId: string): JobError[] => {
|
||||
@@ -59,5 +90,5 @@ export const clearAllJobErrors = () => {
|
||||
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch { }
|
||||
} catch {}
|
||||
};
|
||||
|
||||
@@ -5,19 +5,26 @@ export const cn = (...inputs: ClassValue[]) => {
|
||||
return twMerge(clsx(inputs));
|
||||
};
|
||||
|
||||
type TranslationFunction = (key: string) => string;
|
||||
|
||||
export const getTranslations = async (
|
||||
locale: string = process.env.LOCALE || "en"
|
||||
): Promise<TranslationFunction> => {
|
||||
const messages = (await import(`../_translations/${locale}.json`)).default;
|
||||
|
||||
return (key: string) => {
|
||||
const keys = key.split(".");
|
||||
let value: any = messages;
|
||||
for (const k of keys) {
|
||||
value = value?.[k];
|
||||
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;
|
||||
}
|
||||
return value || key;
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Failed to copy to clipboard:", err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
saveRunningJob,
|
||||
updateRunningJob,
|
||||
getRunningJob,
|
||||
removeRunningJob,
|
||||
} from "./running-jobs-utils";
|
||||
import { sseBroadcaster } from "./sse-broadcaster";
|
||||
import { generateLogFolderName } from "./wrapper-utils";
|
||||
import { generateLogFolderName, cleanupOldLogFiles } from "./wrapper-utils";
|
||||
import { watchForLogFile } from "./log-watcher";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -83,18 +85,29 @@ export const runJobInBackground = async (
|
||||
|
||||
child.unref();
|
||||
|
||||
const jobStartTime = new Date();
|
||||
|
||||
saveRunningJob({
|
||||
id: runId,
|
||||
cronJobId: job.id,
|
||||
pid: child.pid!,
|
||||
startTime: new Date().toISOString(),
|
||||
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: new Date().toISOString(),
|
||||
timestamp: jobStartTime.toISOString(),
|
||||
data: {
|
||||
runId,
|
||||
cronJobId: job.id,
|
||||
@@ -112,9 +125,6 @@ export const runJobInBackground = async (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Monitor a running job and update status when complete
|
||||
*/
|
||||
const monitorRunningJob = (runId: string, pid: number): void => {
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
@@ -130,6 +140,15 @@ const monitorRunningJob = (runId: string, pid: number): void => {
|
||||
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) {
|
||||
@@ -176,7 +195,7 @@ const getExitCodeFromLog = async (
|
||||
runId: string
|
||||
): Promise<number | undefined> => {
|
||||
try {
|
||||
const { readdir, readFile } = await import("fs/promises");
|
||||
const { readdir, readFile, access } = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
|
||||
const job = getRunningJob(runId);
|
||||
@@ -185,6 +204,13 @@ const getExitCodeFromLog = async (
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
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 {
|
||||
@@ -156,47 +189,95 @@ export const resumeJobInLines = (
|
||||
|
||||
export const parseCommentMetadata = (
|
||||
commentText: string
|
||||
): { comment: string; logsEnabled: boolean } => {
|
||||
): { comment: string; logsEnabled: boolean; uuid?: string } => {
|
||||
if (!commentText) {
|
||||
return { comment: "", logsEnabled: false };
|
||||
}
|
||||
|
||||
const parts = commentText.split("|").map((p) => p.trim());
|
||||
let comment = parts[0] || "";
|
||||
let comment = "";
|
||||
let logsEnabled = false;
|
||||
let uuid: string | undefined;
|
||||
|
||||
if (parts.length > 1) {
|
||||
// Format: "fccview absolutely rocks | logsEnabled: true"
|
||||
const metadata = parts[1];
|
||||
const logsMatch = metadata.match(/logsEnabled:\s*(true|false)/i);
|
||||
if (logsMatch) {
|
||||
logsEnabled = logsMatch[1].toLowerCase() === "true";
|
||||
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 {
|
||||
// Format: logsEnabled: true
|
||||
const logsMatch = commentText.match(/^logsEnabled:\s*(true|false)$/i);
|
||||
if (logsMatch) {
|
||||
logsEnabled = logsMatch[1].toLowerCase() === "true";
|
||||
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 };
|
||||
return { comment, logsEnabled, uuid };
|
||||
};
|
||||
|
||||
export const formatCommentWithMetadata = (
|
||||
comment: string,
|
||||
logsEnabled: boolean
|
||||
logsEnabled: boolean,
|
||||
uuid: string
|
||||
): string => {
|
||||
const trimmedComment = comment.trim();
|
||||
const metadataParts: string[] = [];
|
||||
|
||||
if (logsEnabled) {
|
||||
return trimmedComment
|
||||
? `${trimmedComment} | logsEnabled: true`
|
||||
: `logsEnabled: true`;
|
||||
metadataParts.push("logsEnabled: true");
|
||||
}
|
||||
|
||||
return trimmedComment;
|
||||
metadataParts.push(`id: ${uuid}`);
|
||||
|
||||
const metadata = metadataParts.join(" | ");
|
||||
|
||||
if (trimmedComment) {
|
||||
return `${trimmedComment} | ${metadata}`;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
export const parseJobsFromLines = (
|
||||
@@ -206,6 +287,7 @@ export const parseJobsFromLines = (
|
||||
const jobs: CronJob[] = [];
|
||||
let currentComment = "";
|
||||
let currentLogsEnabled = false;
|
||||
let currentUuid: string | undefined;
|
||||
let jobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
@@ -228,7 +310,7 @@ export const parseJobsFromLines = (
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED:")) {
|
||||
const commentText = trimmedLine.substring(9).trim();
|
||||
const { comment, logsEnabled } = parseCommentMetadata(commentText);
|
||||
const { comment, logsEnabled, uuid } = parseCommentMetadata(commentText);
|
||||
|
||||
if (i + 1 < lines.length) {
|
||||
const nextLine = lines[i + 1].trim();
|
||||
@@ -239,8 +321,11 @@ 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,
|
||||
@@ -266,9 +351,11 @@ export const parseJobsFromLines = (
|
||||
lines[i + 1].trim()
|
||||
) {
|
||||
const commentText = trimmedLine.substring(1).trim();
|
||||
const { comment, logsEnabled } = parseCommentMetadata(commentText);
|
||||
const { comment, logsEnabled, uuid } =
|
||||
parseCommentMetadata(commentText);
|
||||
currentComment = comment;
|
||||
currentLogsEnabled = logsEnabled;
|
||||
currentUuid = uuid;
|
||||
i++;
|
||||
continue;
|
||||
} else {
|
||||
@@ -291,8 +378,12 @@ 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,
|
||||
@@ -304,6 +395,7 @@ export const parseJobsFromLines = (
|
||||
jobIndex++;
|
||||
currentComment = "";
|
||||
currentLogsEnabled = false;
|
||||
currentUuid = undefined;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
@@ -399,7 +491,8 @@ export const updateJobInLines = (
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = "",
|
||||
logsEnabled: boolean = false
|
||||
logsEnabled: boolean = false,
|
||||
uuid: string
|
||||
): string[] => {
|
||||
const newCronEntries: string[] = [];
|
||||
let currentJobIndex = 0;
|
||||
@@ -433,7 +526,8 @@ export const updateJobInLines = (
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const formattedComment = formatCommentWithMetadata(
|
||||
comment,
|
||||
logsEnabled
|
||||
logsEnabled,
|
||||
uuid
|
||||
);
|
||||
const newEntry = formattedComment
|
||||
? `# PAUSED: ${formattedComment}\n# ${schedule} ${command}`
|
||||
@@ -466,7 +560,8 @@ export const updateJobInLines = (
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const formattedComment = formatCommentWithMetadata(
|
||||
comment,
|
||||
logsEnabled
|
||||
logsEnabled,
|
||||
uuid
|
||||
);
|
||||
const newEntry = formattedComment
|
||||
? `# ${formattedComment}\n${schedule} ${command}`
|
||||
@@ -487,7 +582,11 @@ export const updateJobInLines = (
|
||||
}
|
||||
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const formattedComment = formatCommentWithMetadata(comment, logsEnabled);
|
||||
const formattedComment = formatCommentWithMetadata(
|
||||
comment,
|
||||
logsEnabled,
|
||||
uuid
|
||||
);
|
||||
const newEntry = formattedComment
|
||||
? `# ${formattedComment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
|
||||
@@ -95,3 +95,62 @@ export const stopLogWatcher = () => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -86,4 +86,4 @@ export const getScriptById = (
|
||||
id: string
|
||||
): Script | undefined => {
|
||||
return scripts.find((script) => script.id === id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,12 +15,20 @@ class SSEBroadcaster {
|
||||
controller,
|
||||
connectedAt: new Date(),
|
||||
});
|
||||
console.log(`[SSE] Client ${id} connected. Total clients: ${this.clients.size}`);
|
||||
if (process.env.DEBUGGER) {
|
||||
console.log(
|
||||
`[SSE] Client ${id} connected. Total clients: ${this.clients.size}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
removeClient(id: string): void {
|
||||
this.clients.delete(id);
|
||||
console.log(`[SSE] Client ${id} disconnected. Total clients: ${this.clients.size}`);
|
||||
if (process.env.DEBUGGER) {
|
||||
console.log(
|
||||
`[SSE] Client ${id} disconnected. Total clients: ${this.clients.size}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(event: SSEEvent): void {
|
||||
@@ -36,23 +44,29 @@ class SSEBroadcaster {
|
||||
client.controller.enqueue(encoded);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`[SSE] Failed to send to client ${id}:`, error);
|
||||
if (process.env.DEBUGGER) {
|
||||
console.error(`[SSE] Failed to send to client ${id}:`, error);
|
||||
}
|
||||
this.removeClient(id);
|
||||
failCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (this.clients.size > 0) {
|
||||
console.log(
|
||||
`[SSE] Broadcast ${event.type} to ${successCount} clients (${failCount} failed)`
|
||||
);
|
||||
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) {
|
||||
console.warn(`[SSE] Client ${clientId} not found`);
|
||||
if (process.env.DEBUGGER) {
|
||||
console.warn(`[SSE] Client ${clientId} not found`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +75,9 @@ class SSEBroadcaster {
|
||||
const encoder = new TextEncoder();
|
||||
client.controller.enqueue(encoder.encode(formattedEvent));
|
||||
} catch (error) {
|
||||
console.error(`[SSE] Failed to send to client ${clientId}:`, error);
|
||||
if (process.env.DEBUGGER) {
|
||||
console.error(`[SSE] Failed to send to client ${clientId}:`, error);
|
||||
}
|
||||
this.removeClient(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
15
app/_utils/uuid-utils.ts
Normal 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);
|
||||
};
|
||||
@@ -15,10 +15,6 @@ export const generateLogFolderName = (
|
||||
jobId: string,
|
||||
comment?: string
|
||||
): string => {
|
||||
if (comment && comment.trim()) {
|
||||
const sanitized = sanitizeForFilesystem(comment.trim());
|
||||
return sanitized ? `${sanitized}_${jobId}` : jobId;
|
||||
}
|
||||
return jobId;
|
||||
};
|
||||
|
||||
@@ -38,7 +34,6 @@ export const ensureWrapperScriptInData = (): string => {
|
||||
if (!existsSync(dataScriptPath)) {
|
||||
try {
|
||||
copyFileSync(sourceScriptPath, dataScriptPath);
|
||||
console.log(`Copied wrapper script to ${dataScriptPath}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy wrapper script to data directory:", error);
|
||||
return sourceScriptPath;
|
||||
@@ -105,3 +100,55 @@ export const extractJobIdFromWrappedCommand = (
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const cleanupOldLogFiles = async (
|
||||
jobId: string,
|
||||
maxFiles: number = 10
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { readdir, stat, unlink } = await import("fs/promises");
|
||||
const logFolderName = generateLogFolderName(jobId);
|
||||
const logDir = path.join(process.cwd(), "data", "logs", logFolderName);
|
||||
|
||||
try {
|
||||
await stat(logDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await readdir(logDir);
|
||||
const logFiles = files
|
||||
.filter((f) => f.endsWith(".log"))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: path.join(logDir, f),
|
||||
stats: null as any,
|
||||
}));
|
||||
|
||||
for (const file of logFiles) {
|
||||
try {
|
||||
file.stats = await stat(file.path);
|
||||
} catch (error) {
|
||||
console.error(`Error stat-ing log file ${file.path}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const validFiles = logFiles
|
||||
.filter((f) => f.stats)
|
||||
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime());
|
||||
|
||||
if (validFiles.length > maxFiles) {
|
||||
const filesToDelete = validFiles.slice(maxFiles);
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await unlink(file.path);
|
||||
console.log(`Cleaned up old log file: ${file.path}`);
|
||||
} catch (error) {
|
||||
console.error(`Error deleting log file ${file.path}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error cleaning up log files for job ${jobId}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,10 +3,6 @@ import { validateSession, getSessionCookieName } from "@/app/_utils/session-util
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Validate session for middleware
|
||||
* This runs in Node.js runtime so it can access the filesystem
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const cookieName = getSessionCookieName();
|
||||
const sessionId = request.cookies.get(cookieName)?.value;
|
||||
|
||||
@@ -32,6 +32,13 @@ export const POST = async (request: NextRequest) => {
|
||||
);
|
||||
|
||||
const cookieName = getSessionCookieName();
|
||||
|
||||
if (process.env.DEBUGGER) {
|
||||
console.log("LOGIN - cookieName:", cookieName);
|
||||
console.log("LOGIN - NODE_ENV:", process.env.NODE_ENV);
|
||||
console.log("LOGIN - HTTPS:", process.env.HTTPS);
|
||||
console.log("LOGIN - sessionId:", sessionId.substring(0, 10) + "...");
|
||||
}
|
||||
response.cookies.set(cookieName, sessionId, {
|
||||
httpOnly: true,
|
||||
secure:
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function DELETE(
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const result = await removeCronJob(params.id);
|
||||
const result = await removeCronJob({ id: params.id, schedule: "", command: "", user: "" });
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json(result);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getRunningJob } from "@/app/_utils/running-jobs-utils";
|
||||
import { readFile } from "fs/promises";
|
||||
import { readFile, open } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { requireAuth } from "@/app/_utils/api-auth-utils";
|
||||
@@ -14,6 +14,13 @@ export const GET = async (request: NextRequest) => {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const runId = searchParams.get("runId");
|
||||
const offsetStr = searchParams.get("offset");
|
||||
const offset = offsetStr ? parseInt(offsetStr, 10) : 0;
|
||||
|
||||
const maxLinesStr = searchParams.get("maxLines");
|
||||
const maxLines = maxLinesStr
|
||||
? Math.min(Math.max(parseInt(maxLinesStr, 10), 100), 5000)
|
||||
: 500;
|
||||
|
||||
if (!runId) {
|
||||
return NextResponse.json(
|
||||
@@ -66,16 +73,154 @@ export const GET = async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const sortedFiles = files.sort().reverse();
|
||||
const latestLogFile = path.join(logDir, sortedFiles[0]);
|
||||
|
||||
const content = await readFile(latestLogFile, "utf-8");
|
||||
let latestLogFile: string | null = null;
|
||||
let latestStats: any = null;
|
||||
const jobStartTime = new Date(job.startTime);
|
||||
const TIME_TOLERANCE_MS = 5000;
|
||||
|
||||
if (job.logFileName) {
|
||||
const cachedFilePath = path.join(logDir, job.logFileName);
|
||||
if (existsSync(cachedFilePath)) {
|
||||
try {
|
||||
const { stat } = await import("fs/promises");
|
||||
latestLogFile = cachedFilePath;
|
||||
latestStats = await stat(latestLogFile);
|
||||
} catch (error) {
|
||||
console.error(`Error reading cached log file ${job.logFileName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestLogFile) {
|
||||
for (const file of sortedFiles) {
|
||||
const filePath = path.join(logDir, file);
|
||||
try {
|
||||
const { stat } = await import("fs/promises");
|
||||
const stats = await stat(filePath);
|
||||
const fileCreateTime = stats.birthtime || stats.mtime;
|
||||
|
||||
if (fileCreateTime.getTime() >= jobStartTime.getTime() - TIME_TOLERANCE_MS) {
|
||||
latestLogFile = filePath;
|
||||
latestStats = stats;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking file ${file}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestLogFile && sortedFiles.length > 0) {
|
||||
try {
|
||||
const { stat } = await import("fs/promises");
|
||||
const fallbackPath = path.join(logDir, sortedFiles[0]);
|
||||
const fallbackStats = await stat(fallbackPath);
|
||||
const now = new Date();
|
||||
const fileAge = now.getTime() - (fallbackStats.birthtime || fallbackStats.mtime).getTime();
|
||||
|
||||
if (fileAge <= TIME_TOLERANCE_MS) {
|
||||
latestLogFile = fallbackPath;
|
||||
latestStats = fallbackStats;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error stat-ing fallback file:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestLogFile || !latestStats) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: job.status,
|
||||
content: "",
|
||||
message: "No log file found for this run",
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
const fileSize = latestStats.size;
|
||||
|
||||
let displayedLines: string[] = [];
|
||||
let truncated = false;
|
||||
let totalLines = 0;
|
||||
let content = "";
|
||||
let newContent = "";
|
||||
|
||||
if (offset === 0) {
|
||||
const AVERAGE_LINE_LENGTH = 100;
|
||||
const ESTIMATED_BYTES = maxLines * AVERAGE_LINE_LENGTH * 2;
|
||||
const bytesToRead = Math.min(ESTIMATED_BYTES, fileSize);
|
||||
|
||||
if (bytesToRead < fileSize) {
|
||||
const fileHandle = await open(latestLogFile, "r");
|
||||
const buffer = Buffer.alloc(bytesToRead);
|
||||
await fileHandle.read(buffer, 0, bytesToRead, fileSize - bytesToRead);
|
||||
await fileHandle.close();
|
||||
|
||||
const tailContent = buffer.toString("utf-8");
|
||||
const lines = tailContent.split("\n");
|
||||
|
||||
if (lines[0] && lines[0].length > 0) {
|
||||
lines.shift();
|
||||
}
|
||||
|
||||
if (lines.length > maxLines) {
|
||||
displayedLines = lines.slice(-maxLines);
|
||||
truncated = true;
|
||||
} else {
|
||||
displayedLines = lines;
|
||||
truncated = true;
|
||||
}
|
||||
} else {
|
||||
const fullContent = await readFile(latestLogFile, "utf-8");
|
||||
const allLines = fullContent.split("\n");
|
||||
totalLines = allLines.length;
|
||||
|
||||
if (totalLines > maxLines) {
|
||||
displayedLines = allLines.slice(-maxLines);
|
||||
truncated = true;
|
||||
} else {
|
||||
displayedLines = allLines;
|
||||
}
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
content = `[LOG TRUNCATED - Showing last ${maxLines} lines (${(fileSize / 1024 / 1024).toFixed(2)}MB total)]\n\n` + displayedLines.join("\n");
|
||||
} else {
|
||||
content = displayedLines.join("\n");
|
||||
totalLines = displayedLines.length;
|
||||
}
|
||||
newContent = content;
|
||||
} else {
|
||||
if (offset < fileSize) {
|
||||
const fileHandle = await open(latestLogFile, "r");
|
||||
const bytesToRead = fileSize - offset;
|
||||
const buffer = Buffer.alloc(bytesToRead);
|
||||
await fileHandle.read(buffer, 0, bytesToRead, offset);
|
||||
await fileHandle.close();
|
||||
|
||||
newContent = buffer.toString("utf-8");
|
||||
const newLines = newContent.split("\n").filter(l => l.length > 0);
|
||||
if (newLines.length > 0) {
|
||||
content = newContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: job.status,
|
||||
content,
|
||||
newContent,
|
||||
fullContent: offset === 0 ? content : undefined,
|
||||
logFile: sortedFiles[0],
|
||||
isComplete: job.status !== "running",
|
||||
exitCode: job.exitCode,
|
||||
fileSize,
|
||||
offset,
|
||||
totalLines: offset === 0 && !truncated ? totalLines : undefined,
|
||||
displayedLines: displayedLines.length,
|
||||
truncated,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error streaming log:", error);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTranslations } from "@/app/_utils/global-utils";
|
||||
import { getTranslations } from "@/app/_server/actions/translations";
|
||||
import * as si from "systeminformation";
|
||||
import {
|
||||
getPing,
|
||||
@@ -18,6 +18,11 @@ export const dynamic = "force-dynamic";
|
||||
export const GET = async (request: NextRequest) => {
|
||||
const authError = await requireAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
if (process.env.DISABLE_SYSTEM_STATS === "true") {
|
||||
return NextResponse.json(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const t = await getTranslations();
|
||||
|
||||
@@ -71,8 +76,8 @@ export const GET = async (request: NextRequest) => {
|
||||
network: {
|
||||
speed:
|
||||
mainInterface &&
|
||||
mainInterface.rx_sec != null &&
|
||||
mainInterface.tx_sec != null
|
||||
mainInterface.rx_sec != null &&
|
||||
mainInterface.tx_sec != null
|
||||
? `${Math.round(rxSpeed + txSpeed)} Mbps`
|
||||
: t("system.unknown"),
|
||||
latency: latency,
|
||||
|
||||
38
app/api/system/wrapper-check/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { DATA_DIR } from "@/app/_consts/file";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const officialScriptPath = path.join(
|
||||
process.cwd(),
|
||||
"app",
|
||||
"_scripts",
|
||||
"cron-log-wrapper.sh"
|
||||
);
|
||||
|
||||
const dataScriptPath = path.join(
|
||||
process.cwd(),
|
||||
DATA_DIR,
|
||||
"cron-log-wrapper.sh"
|
||||
);
|
||||
|
||||
if (!existsSync(dataScriptPath)) {
|
||||
return NextResponse.json({ modified: false });
|
||||
}
|
||||
|
||||
const officialScript = readFileSync(officialScriptPath, "utf-8");
|
||||
const dataScript = readFileSync(dataScriptPath, "utf-8");
|
||||
|
||||
const modified = officialScript !== dataScript;
|
||||
|
||||
return NextResponse.json({ modified });
|
||||
} catch (error) {
|
||||
console.error("Error checking wrapper script:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to check wrapper script" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--primary) / 0.8);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
@@ -81,31 +107,26 @@
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
/* Terminal-style fonts for code elements */
|
||||
code, pre, .font-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
}
|
||||
|
||||
/* Brand styling */
|
||||
.brand-text {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* Terminal-style headings */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Terminal-style buttons and inputs */
|
||||
button, input, textarea, select {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Code blocks and terminal areas */
|
||||
.terminal-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
@@ -113,7 +134,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Cyberpunk-inspired gradient background */
|
||||
@layer components {
|
||||
.hero-gradient {
|
||||
background: linear-gradient(135deg,
|
||||
@@ -145,7 +165,6 @@
|
||||
radial-gradient(circle at 40% 40%, hsl(340 100% 45% / 0.06) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
/* Glass morphism cards */
|
||||
.glass-card {
|
||||
@apply backdrop-blur-md bg-card/80 border border-border/50;
|
||||
}
|
||||
@@ -158,7 +177,6 @@
|
||||
@apply glass-card;
|
||||
}
|
||||
|
||||
/* Vibrant gradient text */
|
||||
.brand-gradient {
|
||||
@apply bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 bg-clip-text text-transparent;
|
||||
}
|
||||
@@ -175,7 +193,6 @@
|
||||
@apply bg-gradient-to-r from-cyan-600 via-blue-600 to-purple-600 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* Neon glow effects */
|
||||
.glow-primary {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -196,12 +213,10 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-error {
|
||||
@apply bg-red-500/20 text-red-700 dark:text-red-400 border-red-500/30;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||
@@ -224,7 +239,6 @@
|
||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* Tooltip styles */
|
||||
.tooltip {
|
||||
@apply absolute z-50 px-3 py-2 text-sm text-white bg-gray-900 rounded-lg shadow-lg opacity-0 invisible transition-all duration-200;
|
||||
}
|
||||
@@ -233,7 +247,6 @@
|
||||
@apply opacity-100 visible;
|
||||
}
|
||||
|
||||
/* Responsive text utilities */
|
||||
.text-responsive {
|
||||
@apply text-sm sm:text-base lg:text-lg;
|
||||
}
|
||||
@@ -246,7 +259,6 @@
|
||||
@apply text-lg sm:text-xl lg:text-2xl;
|
||||
}
|
||||
|
||||
/* Button variants with new colors */
|
||||
.btn-primary {
|
||||
@apply bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 transition-all;
|
||||
}
|
||||
@@ -279,7 +291,6 @@
|
||||
@apply bg-gradient-to-r from-red-600 to-pink-600 text-white hover:from-red-700 hover:to-pink-700 transition-all;
|
||||
}
|
||||
|
||||
/* Neon accent borders */
|
||||
.neon-border {
|
||||
border: 1px solid transparent;
|
||||
background: linear-gradient(white, white) padding-box,
|
||||
@@ -295,6 +306,20 @@
|
||||
|
||||
@layer utilities {
|
||||
body.sidebar-collapsed main.lg\:ml-80 {
|
||||
margin-left: 4rem !important; /* 64px */
|
||||
margin-left: 4rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-overflow-fix {
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-overflow-fix > * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-overflow-fix .dropdown-container,
|
||||
.dropdown-overflow-fix [class*="dropdown"] {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
40
app/i18n.ts
@@ -1,24 +1,24 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { Locales } from "@/app/_consts/global";
|
||||
|
||||
const validLocales = Locales.map((item) => item.locale);
|
||||
import { loadTranslationMessages } from "@/app/_server/actions/translations";
|
||||
|
||||
export default getRequestConfig(async ({ locale }) => {
|
||||
const safeLocale = locale && validLocales.includes(locale) ? locale : "en";
|
||||
const safeLocale = locale || "en";
|
||||
|
||||
try {
|
||||
return {
|
||||
locale: safeLocale,
|
||||
messages: (await import(`./_translations/${safeLocale}.json`)).default,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to load translations for locale: ${safeLocale}`,
|
||||
error
|
||||
);
|
||||
return {
|
||||
locale: "en",
|
||||
messages: (await import("./_translations/en.json")).default,
|
||||
};
|
||||
}
|
||||
});
|
||||
try {
|
||||
const messages = await loadTranslationMessages(safeLocale);
|
||||
return {
|
||||
locale: safeLocale,
|
||||
messages,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to load translations for locale: ${safeLocale}`,
|
||||
error
|
||||
);
|
||||
const fallbackMessages = await loadTranslationMessages("en");
|
||||
return {
|
||||
locale: "en",
|
||||
messages: fallbackMessages,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { JetBrains_Mono, Inter } from "next/font/google";
|
||||
import "@/app/globals.css";
|
||||
import { ThemeProvider } from "@/app/_providers/ThemeProvider";
|
||||
import { ServiceWorkerRegister } from "@/app/_components/FeatureComponents/PWA/ServiceWorkerRegister";
|
||||
import { Locales } from "@/app/_consts/global";
|
||||
import { loadTranslationMessages } from "@/app/_server/actions/translations";
|
||||
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
@@ -21,7 +21,8 @@ const inter = Inter({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Cr*nMaster - Cron Management made easy",
|
||||
description: "The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
|
||||
description:
|
||||
"The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
@@ -54,12 +55,7 @@ export default async function RootLayout({
|
||||
let locale = process.env.LOCALE || "en";
|
||||
let messages;
|
||||
|
||||
|
||||
if (!Locales.some((item) => item.locale === locale)) {
|
||||
locale = "en";
|
||||
}
|
||||
|
||||
messages = (await import(`./_translations/${locale}.json`)).default;
|
||||
messages = await loadTranslationMessages(locale);
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
@@ -72,7 +68,6 @@ export default async function RootLayout({
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
</head>
|
||||
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans`}>
|
||||
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
'use server';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { LoginForm } from "@/app/_components/FeatureComponents/LoginForm/LoginForm";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export default async function LoginPage() {
|
||||
const hasPassword = !!process.env.AUTH_PASSWORD;
|
||||
const hasOIDC = process.env.SSO_MODE === "oidc";
|
||||
const hasPassword = !!process.env.AUTH_PASSWORD;
|
||||
const hasOIDC = process.env.SSO_MODE === "oidc";
|
||||
const oidcAutoRedirect = process.env.OIDC_AUTO_REDIRECT === "true";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
<div className="hero-gradient absolute inset-0 -z-10"></div>
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
|
||||
<LoginForm hasPassword={hasPassword} hasOIDC={hasOIDC} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const packageJsonPath = path.join(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||
const version = packageJson.version;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
<div className="hero-gradient absolute inset-0 -z-10"></div>
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
|
||||
<LoginForm
|
||||
hasPassword={hasPassword}
|
||||
hasOIDC={hasOIDC}
|
||||
oidcAutoRedirect={oidcAutoRedirect}
|
||||
version={version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
15
app/page.tsx
@@ -6,7 +6,8 @@ import { ThemeToggle } from "@/app/_components/FeatureComponents/Theme/ThemeTogg
|
||||
import { LogoutButton } from "@/app/_components/FeatureComponents/LoginForm/LogoutButton";
|
||||
import { ToastContainer } from "@/app/_components/GlobalComponents/UIElements/Toast";
|
||||
import { PWAInstallPrompt } from "@/app/_components/FeatureComponents/PWA/PWAInstallPrompt";
|
||||
import { getTranslations } from "@/app/_utils/global-utils";
|
||||
import { WrapperScriptWarning } from "@/app/_components/FeatureComponents/System/WrapperScriptWarning";
|
||||
import { getTranslations } from "@/app/_server/actions/translations";
|
||||
import { SSEProvider } from "@/app/_contexts/SSEContext";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -14,7 +15,10 @@ export const maxDuration = 300;
|
||||
|
||||
export default async function Home() {
|
||||
const t = await getTranslations();
|
||||
const liveUpdatesEnabled = (typeof process.env.LIVE_UPDATES === "boolean" && process.env.LIVE_UPDATES === true) || process.env.LIVE_UPDATES !== "false";
|
||||
const liveUpdatesEnabled =
|
||||
(typeof process.env.LIVE_UPDATES === "boolean" &&
|
||||
process.env.LIVE_UPDATES === true) ||
|
||||
process.env.LIVE_UPDATES !== "false";
|
||||
|
||||
const [cronJobs, scripts] = await Promise.all([
|
||||
getCronJobs(),
|
||||
@@ -86,10 +90,13 @@ export default async function Home() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<SystemInfoCard systemInfo={initialSystemInfo} />
|
||||
{process.env.DISABLE_SYSTEM_STATS !== "true" && (
|
||||
<SystemInfoCard systemInfo={initialSystemInfo} />
|
||||
)}
|
||||
|
||||
<main className="lg:ml-80 transition-all duration-300 ml-0 sidebar-collapsed:lg:ml-16">
|
||||
<main className={`${process.env.DISABLE_SYSTEM_STATS === "true" ? "lg:ml-0" : "lg:ml-80"} transition-all duration-300 ml-0 sidebar-collapsed:lg:ml-16`}>
|
||||
<div className="container mx-auto px-4 py-8 lg:px-8">
|
||||
<WrapperScriptWarning />
|
||||
<TabbedInterface cronJobs={cronJobs} scripts={scripts} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
227
howto/API.md
@@ -106,6 +106,104 @@ curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/cronjobs/:id
|
||||
|
||||
Update a cron job.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `id` (string) - Cron job ID
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"schedule": "0 3 * * *",
|
||||
"command": "/usr/bin/echo updated",
|
||||
"comment": "Updated job",
|
||||
"logsEnabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Cron job updated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -X PATCH \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schedule":"0 3 * * *","command":"/usr/bin/echo updated"}' \
|
||||
https://your-cronmaster-url.com/api/cronjobs/fccview-0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/cronjobs/:id
|
||||
|
||||
Delete a cron job.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `id` (string) - Cron job ID
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Cron job deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
https://your-cronmaster-url.com/api/cronjobs/fccview-0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/cronjobs/:id/execute
|
||||
|
||||
Manually execute a cron job.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `id` (string) - Cron job ID
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `runInBackground` (boolean, optional) - Whether to run the job in background. Defaults to `true`.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"runId": "run-123",
|
||||
"message": "Job execution started"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
https://your-cronmaster-url.com/api/cronjobs/fccview-0/execute?runInBackground=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/scripts
|
||||
|
||||
List all scripts.
|
||||
@@ -196,6 +294,127 @@ curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
|
||||
---
|
||||
|
||||
### GET /api/logs/stream
|
||||
|
||||
Stream job execution logs.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `runId` (string, required) - The run ID of the job execution
|
||||
- `offset` (number, optional) - Byte offset for streaming new content. Defaults to `0`.
|
||||
- `maxLines` (number, optional) - Maximum lines to return. Defaults to `500`, min `100`, max `5000`.
|
||||
|
||||
**Note:** When `offset=0`, the endpoint only reads the last `maxLines` from the file for performance. This means `totalLines` is only returned when the file is small enough to read entirely (not truncated).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"content": "[log content]",
|
||||
"newContent": "[new log content since offset]",
|
||||
"logFile": "2025-11-10_14-30-00.log",
|
||||
"isComplete": false,
|
||||
"exitCode": null,
|
||||
"fileSize": 1024,
|
||||
"offset": 0,
|
||||
"totalLines": 50,
|
||||
"displayedLines": 50,
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response Fields:**
|
||||
|
||||
- `status` (string) - Job status: "running", "completed", or "failed"
|
||||
- `content` (string) - The log content to display
|
||||
- `newContent` (string) - New content since the last offset (for streaming)
|
||||
- `logFile` (string) - Name of the log file
|
||||
- `isComplete` (boolean) - Whether the job has completed
|
||||
- `exitCode` (number | null) - Exit code of the job (null if still running)
|
||||
- `fileSize` (number) - Total size of the log file in bytes
|
||||
- `offset` (number) - Current byte offset
|
||||
- `totalLines` (number | undefined) - Total number of lines in the file (only returned when file is small enough to read entirely)
|
||||
- `displayedLines` (number) - Number of lines being displayed
|
||||
- `truncated` (boolean) - Whether the content is truncated due to maxLines limit
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
"https://your-cronmaster-url.com/api/logs/stream?runId=run-123&offset=0&maxLines=500"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/system/wrapper-check
|
||||
|
||||
Check if the log wrapper script has been modified from the default.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"modified": false
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
https://your-cronmaster-url.com/api/system/wrapper-check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/oidc/login
|
||||
|
||||
Initiate OIDC (SSO) login flow. Redirects to the OIDC provider's authorization endpoint.
|
||||
|
||||
**Note:** This endpoint is only available when `SSO_MODE=oidc` is configured.
|
||||
|
||||
**Response:** HTTP 302 redirect to OIDC provider
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -L https://your-cronmaster-url.com/api/oidc/login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/oidc/callback
|
||||
|
||||
OIDC callback endpoint. Handles the authorization code from the OIDC provider and creates a session.
|
||||
|
||||
**Note:** This endpoint is typically called by the OIDC provider after authentication, not directly by clients.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `code` (string) - Authorization code from OIDC provider
|
||||
- `state` (string) - State parameter for CSRF protection
|
||||
|
||||
**Response:** HTTP 302 redirect to application root
|
||||
|
||||
---
|
||||
|
||||
### GET /api/oidc/logout
|
||||
|
||||
Initiate OIDC logout flow. Redirects to the OIDC provider's logout endpoint.
|
||||
|
||||
**Note:** This endpoint is only available when `SSO_MODE=oidc` is configured.
|
||||
|
||||
**Response:** HTTP 302 redirect to OIDC provider logout endpoint
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -L https://your-cronmaster-url.com/api/oidc/logout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/auth/login
|
||||
|
||||
Login with password (alternative to API key).
|
||||
@@ -264,11 +483,3 @@ Logout and clear session (requires login first).
|
||||
"message": "Authentication required. Use session cookie or API key (Bearer token)."
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
For local testing I have made a node script that checks all available endpoints:
|
||||
|
||||
```bash
|
||||
AUTH_PASSWORD=your-password node test-api.js https://your-cronmaster-url.com
|
||||
```
|
||||
|
||||
@@ -52,7 +52,7 @@ environment:
|
||||
#### Localization
|
||||
|
||||
```yaml
|
||||
- LOCALE=en # or other supported locales (see /app/_translations/)
|
||||
- LOCALE=en # or any locale code (supports custom translations in ./data/translations/)
|
||||
```
|
||||
|
||||
#### Logging Configuration
|
||||
|
||||
@@ -15,10 +15,35 @@ This document provides a comprehensive reference for all environment variables u
|
||||
| Variable | Default | Description |
|
||||
| --------------- | ------------- | ---------------------------------------------------------------------------- |
|
||||
| `APP_URL` | Auto-detected | Public URL of your Cronmaster instance (e.g., `https://cron.yourdomain.com`) |
|
||||
| `LOCALE` | `en` | Application locale/language setting |
|
||||
| `LOCALE` | `en` | Application locale/language setting (supports custom translations) |
|
||||
| `HOME` | `/home` | Path to home directory (optional override) |
|
||||
| `AUTH_PASSWORD` | `N/A` | Password for authentication (can be used alone or with SSO) |
|
||||
|
||||
## Custom Translations
|
||||
|
||||
CronMaster supports custom user-made translations. You can create your own translation files and use them by setting the `LOCALE` environment variable.
|
||||
|
||||
**For detailed instructions on creating custom translations or contributing official translations, see [TRANSLATIONS.md](TRANSLATIONS.md).**
|
||||
|
||||
### Quick Setup for Custom Translations
|
||||
|
||||
```bash
|
||||
# Create translations directory
|
||||
mkdir -p ./data/translations
|
||||
|
||||
# Copy template and customize
|
||||
cp app/_translations/en.json ./data/translations/your-locale.json
|
||||
|
||||
# Set locale and restart
|
||||
export LOCALE=your-locale
|
||||
```
|
||||
|
||||
Translation loading priority:
|
||||
|
||||
1. Custom: `./data/translations/{locale}.json`
|
||||
2. Built-in: `app/_translations/{locale}.json`
|
||||
3. Fallback: `app/_translations/en.json`
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -31,13 +56,15 @@ This document provides a comprehensive reference for all environment variables u
|
||||
| ----------------------------------- | ------- | -------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL` | `30000` | Clock update interval in milliseconds (30 seconds) |
|
||||
| `LIVE_UPDATES` | `true` | Enable/disable Server-Sent Events for live updates |
|
||||
| `DISABLE_SYSTEM_STATS` | `false` | Set to `true` to completely disable system stats (stops polling and hides sidebar) |
|
||||
|
||||
## Logging Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------ | ------- | ---------------------------------------------- |
|
||||
| `MAX_LOG_AGE_DAYS` | `30` | Days to keep job execution logs before cleanup |
|
||||
| `MAX_LOGS_PER_JOB` | `50` | Maximum number of log files to keep per job |
|
||||
| Variable | Default | Description |
|
||||
| ------------------------------ | ------- | ---------------------------------------------------------------- |
|
||||
| `MAX_LOG_AGE_DAYS` | `30` | Days to keep job execution logs before cleanup |
|
||||
| `NEXT_PUBLIC_MAX_LOG_AGE_DAYS` | `30` | Days to keep error history in browser localStorage (client-side) |
|
||||
| `MAX_LOGS_PER_JOB` | `50` | Maximum number of log files to keep per job |
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
@@ -58,6 +85,7 @@ This document provides a comprehensive reference for all environment variables u
|
||||
| `OIDC_CLIENT_SECRET` | `N/A` | OIDC client secret (optional, for confidential clients) |
|
||||
| `OIDC_LOGOUT_URL` | `N/A` | Custom logout URL for OIDC provider |
|
||||
| `OIDC_GROUPS_SCOPE` | `groups` | Scope for requesting user groups |
|
||||
| `OIDC_AUTO_REDIRECT` | `false` | Automatically redirect to OIDC provider when it's the only authentication method (no password set) |
|
||||
| `INTERNAL_API_URL` | `http://localhost:3000` | Internal API URL override for specific nginx configurations with SSO |
|
||||
|
||||
### API Authentication
|
||||
@@ -108,7 +136,7 @@ services:
|
||||
- AUTH_PASSWORD=your_secure_password
|
||||
- HOST_CRONTAB_USER=root
|
||||
- APP_URL=https://cron.yourdomain.com
|
||||
- LOCALE=en
|
||||
- LOCALE=en
|
||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||
- LIVE_UPDATES=true
|
||||
- MAX_LOG_AGE_DAYS=30
|
||||
@@ -118,6 +146,7 @@ services:
|
||||
- OIDC_CLIENT_ID=your_client_id
|
||||
- OIDC_CLIENT_SECRET=your_client_secret
|
||||
- OIDC_LOGOUT_URL=https://auth.yourdomain.com/logout
|
||||
- OIDC_AUTO_REDIRECT=true
|
||||
- API_KEY=your_api_key
|
||||
```
|
||||
|
||||
|
||||
93
howto/LOGS.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 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:
|
||||
|
||||
```
|
||||
--- [ JOB START ] ----------------------------------------------------
|
||||
Command : bash /app/scripts/backup.sh
|
||||
Timestamp : 2025-11-10 14:30:00
|
||||
Host : hostname
|
||||
User : root
|
||||
--- [ JOB OUTPUT ] ---------------------------------------------------
|
||||
|
||||
[command output here]
|
||||
|
||||
--- [ JOB SUMMARY ] --------------------------------------------------
|
||||
Timestamp : 2025-11-10 14:30:45
|
||||
Duration : 45s
|
||||
Exit Code : 0
|
||||
Status : SUCCESS
|
||||
--- [ JOB END ] ------------------------------------------------------
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
415
howto/TRANSLATIONS.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# Translation Guide
|
||||
|
||||
CronMaster supports internationalization (i18n) with both **unofficial custom translations** and **official translations** that can be contributed to the project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Custom User Translations (Unofficial)](#custom-user-translations-unofficial)
|
||||
- [Official Translations via Pull Request](#official-translations-via-pull-request)
|
||||
- [Translation File Structure](#translation-file-structure)
|
||||
- [Testing Your Translations](#testing-your-translations)
|
||||
- [Translation Guidelines](#translation-guidelines)
|
||||
|
||||
## Custom User Translations (Unofficial)
|
||||
|
||||
You can create your own translation files locally without modifying the source code. These translations are loaded from the `./data/translations/` directory.
|
||||
|
||||
### Quick Setup
|
||||
|
||||
1. **Create the translations directory:**
|
||||
|
||||
```bash
|
||||
mkdir -p ./data/translations
|
||||
```
|
||||
|
||||
2. **Copy a template:**
|
||||
|
||||
```bash
|
||||
cp app/_translations/en.json ./data/translations/your-locale.json
|
||||
```
|
||||
|
||||
**Note**: for docker users, you can copy the translation template from the [source code](https://github.com/fccview/cronmaster/blob/main/app/_translations/en.json)
|
||||
|
||||
3. **Set your locale:**
|
||||
```bash
|
||||
export LOCALE=your-locale
|
||||
```
|
||||
|
||||
### Step-by-Step Guide
|
||||
|
||||
#### 1. Prepare the Directory Structure
|
||||
|
||||
```bash
|
||||
# Create translations directory in your data folder
|
||||
mkdir -p ./data/translations
|
||||
|
||||
# Verify the structure
|
||||
ls -la ./data/translations/
|
||||
```
|
||||
|
||||
#### 2. Create Your Translation File
|
||||
|
||||
Use English as a template and create your locale file:
|
||||
|
||||
```bash
|
||||
# Copy English template
|
||||
cp app/_translations/en.json ./data/translations/fr.json
|
||||
|
||||
# Or for any other locale
|
||||
cp app/_translations/en.json ./data/translations/es.json
|
||||
cp app/_translations/en.json ./data/translations/de.json
|
||||
```
|
||||
|
||||
#### 3. Edit Your Translation
|
||||
|
||||
Open your translation file and modify the values:
|
||||
|
||||
```bash
|
||||
# Edit with your preferred editor
|
||||
nano ./data/translations/fr.json
|
||||
# or
|
||||
code ./data/translations/fr.json
|
||||
```
|
||||
|
||||
#### 4. Configure Environment
|
||||
|
||||
Set the `LOCALE` environment variable to your locale code:
|
||||
|
||||
```bash
|
||||
# For French
|
||||
export LOCALE=fr
|
||||
|
||||
# For Spanish
|
||||
export LOCALE=es
|
||||
|
||||
# For German
|
||||
export LOCALE=de
|
||||
```
|
||||
|
||||
#### 5. Restart the Application
|
||||
|
||||
Restart CronMaster to load the new translations:
|
||||
|
||||
```bash
|
||||
# If running with npm/yarn
|
||||
npm restart
|
||||
# or
|
||||
yarn restart
|
||||
|
||||
# If using Docker
|
||||
docker-compose restart cronmaster
|
||||
```
|
||||
|
||||
### Translation Priority
|
||||
|
||||
Translations are loaded in this order:
|
||||
|
||||
1. **Custom**: `./data/translations/{locale}.json` (highest priority)
|
||||
2. **Built-in**: `app/_translations/{locale}.json`
|
||||
3. **Fallback**: `app/_translations/en.json` (English)
|
||||
|
||||
This means you can override any built-in translation by creating a custom file with the same locale code.
|
||||
|
||||
## Official Translations via Pull Request
|
||||
|
||||
To contribute an official translation to the CronMaster project, you'll need to create a pull request **targeting the `develop` branch**. All feature contributions, including translations, are merged into `develop` first before being released to `main`.
|
||||
|
||||
**Important:** Do not target the `main` branch directly. All pull requests should be made against `develop`.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Basic knowledge of Git and GitHub
|
||||
- Understanding of JSON format
|
||||
- Familiarity with the CronMaster interface
|
||||
- Accuracy in translation
|
||||
|
||||
### Step-by-Step Contribution Process
|
||||
|
||||
#### 1. Fork the Repository
|
||||
|
||||
```bash
|
||||
# Fork the repository on GitHub
|
||||
# Visit: https://github.com/fccview/cronmaster
|
||||
# Click "Fork" button in the top right
|
||||
```
|
||||
|
||||
#### 2. Clone Your Fork
|
||||
|
||||
```bash
|
||||
# Clone your fork locally
|
||||
git clone https://github.com/YOUR_USERNAME/cronmaster.git
|
||||
cd cronmaster
|
||||
|
||||
# Add upstream remote
|
||||
git remote add upstream https://github.com/fccview/cronmaster.git
|
||||
```
|
||||
|
||||
#### 3. Create a Feature Branch
|
||||
|
||||
```bash
|
||||
# First, ensure you're on the develop branch
|
||||
git checkout develop
|
||||
git pull upstream develop
|
||||
|
||||
# Then create and switch to a new feature branch
|
||||
git checkout -b feature/add-locale-XX
|
||||
|
||||
# Example for Spanish:
|
||||
git checkout develop
|
||||
git pull upstream develop
|
||||
git checkout -b feature/add-locale-es
|
||||
|
||||
# Example for French:
|
||||
git checkout develop
|
||||
git pull upstream develop
|
||||
git checkout -b feature/add-locale-fr
|
||||
```
|
||||
|
||||
#### 4. Create the Translation File
|
||||
|
||||
```bash
|
||||
# Copy the English template
|
||||
cp app/_translations/en.json app/_translations/LOCALE.json
|
||||
|
||||
# Replace LOCALE with your locale code (e.g., es, fr, de, it, pt, etc.)
|
||||
cp app/_translations/en.json app/_translations/es.json
|
||||
```
|
||||
|
||||
#### 5. Translate the Content
|
||||
|
||||
Open your translation file and translate all values:
|
||||
|
||||
```bash
|
||||
# Edit the translation file
|
||||
nano app/_translations/es.json
|
||||
# or
|
||||
code app/_translations/es.json
|
||||
```
|
||||
|
||||
**Important:** Do not change the JSON keys, only translate the string values.
|
||||
|
||||
#### 6. Test Your Translation
|
||||
|
||||
```bash
|
||||
# Set your locale for testing
|
||||
export LOCALE=es
|
||||
|
||||
# Start the development server
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
|
||||
# Visit http://localhost:3000 and verify translations
|
||||
```
|
||||
|
||||
#### 7. Commit Your Changes
|
||||
|
||||
```bash
|
||||
# Add your translation file
|
||||
git add app/_translations/es.json
|
||||
|
||||
# Commit with a descriptive message
|
||||
git commit -m "feat: add Spanish (es) translation
|
||||
|
||||
- Complete Spanish translation for all UI strings
|
||||
- Tested with LOCALE=es environment variable
|
||||
- Follows translation guidelines and structure"
|
||||
```
|
||||
|
||||
#### 8. Push to Your Fork
|
||||
|
||||
```bash
|
||||
# Push your branch to GitHub
|
||||
git push origin feature/add-locale-es
|
||||
```
|
||||
|
||||
#### 9. Create a Pull Request
|
||||
|
||||
1. Visit your fork on GitHub
|
||||
2. Click "Compare & pull request" for your branch
|
||||
3. **Important:** Ensure the pull request targets the `develop` branch (not `main`)
|
||||
- The "base repository" should be `fccview/cronmaster`
|
||||
- The "base" branch should be `develop`
|
||||
- The "head repository" should be `YOUR_USERNAME/cronmaster`
|
||||
- The "compare" branch should be `feature/add-locale-XX`
|
||||
4. Fill out the pull request template:
|
||||
|
||||
**Title:** `feat: add Spanish (es) translation`
|
||||
|
||||
**Description:**
|
||||
|
||||
```markdown
|
||||
## Description
|
||||
|
||||
This PR adds official Spanish translation support to CronMaster.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added `app/_translations/es.json` with complete Spanish translations
|
||||
- All UI strings have been translated accurately
|
||||
- Translation structure matches the English template
|
||||
|
||||
## Testing
|
||||
|
||||
- Tested with `LOCALE=es` environment variable
|
||||
- Verified all pages and components display correctly
|
||||
- No broken translations or missing keys
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Translation is complete (all keys translated)
|
||||
- [x] No JSON syntax errors
|
||||
- [x] Follows translation guidelines
|
||||
- [x] Tested in development environment
|
||||
- [x] Commit message follows conventional format
|
||||
```
|
||||
|
||||
#### 10. Address Review Feedback
|
||||
|
||||
The maintainers may request changes. Make any necessary updates:
|
||||
|
||||
```bash
|
||||
# Make changes based on feedback
|
||||
git add app/_translations/es.json
|
||||
git commit -m "fix: update Spanish translations based on review feedback"
|
||||
git push origin feature/add-locale-es
|
||||
```
|
||||
|
||||
### Pull Request Requirements
|
||||
|
||||
Your pull request must meet these criteria:
|
||||
|
||||
- [ ] **Complete Translation**: All keys from `en.json` must be translated
|
||||
- [ ] **Valid JSON**: No syntax errors, proper escaping
|
||||
- [ ] **Accurate Translation**: Professional, accurate translations
|
||||
- [ ] **Consistent Terminology**: Use consistent terms throughout
|
||||
- [ ] **Cultural Adaptation**: Adapt content appropriately for the locale
|
||||
- [ ] **Tested**: Verified working in the application
|
||||
- [ ] **Proper Commit**: Follows conventional commit format
|
||||
|
||||
### Translation Standards
|
||||
|
||||
- Use proper grammar and punctuation
|
||||
- Maintain consistent terminology
|
||||
- Keep technical terms in English when appropriate
|
||||
- Use appropriate formality level for the target language
|
||||
- Consider cultural context and conventions
|
||||
- Keep translations concise but complete
|
||||
|
||||
## Translation File Structure
|
||||
|
||||
All translation files follow this JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"cronManagementMadeEasy": "Cron Management made easy",
|
||||
"user": "User",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close"
|
||||
},
|
||||
"cronjobs": {
|
||||
"cronJobs": "Cron Jobs",
|
||||
"scheduledTasks": "Scheduled Tasks"
|
||||
},
|
||||
"scripts": {
|
||||
"bashScripts": "Bash Scripts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Guidelines
|
||||
|
||||
- **Keys remain unchanged**: Never modify the JSON keys
|
||||
- **Values are translated**: Only translate the string values
|
||||
- **Hierarchy preserved**: Maintain the nested object structure
|
||||
- **Data types maintained**: Keep arrays as arrays, objects as objects
|
||||
|
||||
## Testing Your Translations
|
||||
|
||||
### Development Testing
|
||||
|
||||
```bash
|
||||
# Set your locale
|
||||
export LOCALE=your-locale
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Test all pages and features:
|
||||
# - Main dashboard
|
||||
# - Cron job management
|
||||
# - Script editor
|
||||
# - Settings pages
|
||||
# - Error messages
|
||||
# - Modal dialogs
|
||||
```
|
||||
|
||||
### Docker Testing
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
cronmaster:
|
||||
environment:
|
||||
- LOCALE=your-locale
|
||||
volumes:
|
||||
- ./data/translations:/app/data/translations:ro
|
||||
```
|
||||
|
||||
### Validation Checklist
|
||||
|
||||
- [ ] All pages load without errors
|
||||
- [ ] No untranslated strings (should show key names if missing)
|
||||
- [ ] Text fits within UI components
|
||||
- [ ] Pluralization works correctly (if applicable)
|
||||
- [ ] Special characters display correctly
|
||||
- [ ] Date/time formats are appropriate for locale
|
||||
|
||||
## Translation Guidelines
|
||||
|
||||
### General Principles
|
||||
|
||||
1. **Accuracy**: Provide accurate, professional translations
|
||||
2. **Consistency**: Use consistent terminology throughout
|
||||
3. **Context Awareness**: Consider UI context and user expectations
|
||||
4. **Cultural Sensitivity**: Adapt content appropriately for the culture
|
||||
5. **Technical Precision**: Maintain technical accuracy for cron/bash concepts
|
||||
|
||||
### Technical Terms
|
||||
|
||||
Some terms should remain in English:
|
||||
|
||||
- "Cron" (the utility name)
|
||||
- "Bash" (the shell name)
|
||||
- Technical file formats (JSON, YAML, etc.)
|
||||
- Command names and parameters
|
||||
- Status messages that are code-related
|
||||
|
||||
### UI-Specific Considerations
|
||||
|
||||
- **Button labels**: Keep short and actionable
|
||||
- **Error messages**: Clear and helpful
|
||||
- **Navigation**: Consistent with user expectations
|
||||
- **Date/Time**: Use locale-appropriate formats
|
||||
- **Numbers**: Follow locale conventions
|
||||
|
||||
### Quality Assurance
|
||||
|
||||
Before submitting:
|
||||
|
||||
- Proofread all translations
|
||||
- Test in context of the application
|
||||
- Verify no broken JSON syntax
|
||||
- Ensure all keys are translated
|
||||
- Check for consistent style and tone
|
||||
|
||||
## Need Help?
|
||||
|
||||
- **Issues**: Report translation bugs or request new locales
|
||||
- **Discussions**: Discuss translation approaches and guidelines
|
||||
- **Discord**: Join our community for translation help
|
||||
|
||||
---
|
||||
|
||||
**Note**: This guide applies to CronMaster version 1.x and later. For older versions, translations must be contributed directly to the main repository.
|
||||
@@ -37,6 +37,9 @@ export const middleware = async (request: NextRequest) => {
|
||||
const sessionId = request.cookies.get(cookieName)?.value;
|
||||
|
||||
if (process.env.DEBUGGER) {
|
||||
console.log("MIDDLEWARE - cookieName:", cookieName);
|
||||
console.log("MIDDLEWARE - NODE_ENV:", process.env.NODE_ENV);
|
||||
console.log("MIDDLEWARE - HTTPS:", process.env.HTTPS);
|
||||
console.log("MIDDLEWARE - sessionId:", sessionId);
|
||||
console.log("MIDDLEWARE - cookies:", request.cookies.getAll());
|
||||
}
|
||||
|
||||
2
next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cronjob-manager",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -13,6 +13,7 @@
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.8.5",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
@@ -31,7 +32,7 @@
|
||||
"jose": "^6.1.1",
|
||||
"lucide-react": "^0.294.0",
|
||||
"minimatch": "^10.0.3",
|
||||
"next": "14.0.4",
|
||||
"next": "14.2.35",
|
||||
"next-intl": "^4.4.0",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.2.1",
|
||||
|
||||
BIN
screenshots/backup.png
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
screenshots/home.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 729 KiB |
BIN
screenshots/live-running.png
Normal file
|
After Width: | Height: | Size: 355 KiB |
BIN
screenshots/logs.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 510 KiB |