26 Commits

Author SHA1 Message Date
fccview
e40b0c0f63 bump package version 2025-12-14 09:06:55 +00:00
fccview
79fd223416 fix documentation, patch next vulnerability and deal with the next upgrad annoyances 2025-12-14 09:06:19 +00:00
fccview
eaca3fe44a remove test scripts 2025-11-20 19:00:14 +00:00
fccview
e033caacf6 try a new strategy for log watch 2025-11-20 18:59:01 +00:00
fccview
d26ce0e810 update package.json version 2025-11-20 07:26:00 +00:00
fccview
7954111d05 fix api auth issue 2025-11-20 07:22:51 +00:00
fccview
f53905c002 make sure evil single quote wont cause issues 2025-11-19 20:41:43 +00:00
fccview
90775cac7c add optional variable to automatically go to oidc provider rather than manually click on a button 2025-11-19 20:35:02 +00:00
fccview
54188eb1c0 limit max output of live logs and paginate 2025-11-19 20:23:14 +00:00
fccview
bf208e3075 fix draft feature and copy button 2025-11-19 20:01:27 +00:00
fccview
a5fb5ff484 update package.json version 2025-11-19 16:48:45 +00:00
fccview
25190f3154 Merge branch 'feature/1-5-0-improvements' into develop 2025-11-19 16:47:54 +00:00
fccview
437bdbd81f fix parenthesis issue and give immutable ids to old jobs too 2025-11-19 16:47:43 +00:00
fccview
d8ab3839c6 Merge branch 'main' into develop 2025-11-19 15:54:58 +00:00
fccview
13fe6c5f3d fix bash language highlight and enhance editor with tabbing functionality 2025-11-19 15:54:42 +00:00
fccview
9fb904d68a Merge pull request #51 from fccview/develop
fix issue with overflow for jobs
2025-11-13 20:13:34 +00:00
fccview
b95cd79239 add min height 2025-11-13 20:09:21 +00:00
fccview
7a4a22f8e9 fix issue with overflow for jobs 2025-11-13 20:03:29 +00:00
fccview
df6ab8774d Merge pull request #49 from fccview/develop
Lift off!!!
2025-11-13 16:07:50 +00:00
fccview
feeb56ece8 finish readme and prepare release 2025-11-13 15:57:31 +00:00
fccview
1b6f5b6e34 fix minor styling, allow custom translations and prepare for release 2025-11-13 15:11:49 +00:00
fccview
1f2379db59 fix minor styling 2025-11-13 14:55:21 +00:00
fccview
ef5153ce54 improve performance, add minimal mode, make UI make more sense 2025-11-13 14:30:21 +00:00
fccview
8faf4d26d0 send fixes to develop for automatic ID assignation 2025-11-12 20:56:54 +00:00
fccview
1fd2689296 add copy id/command 2025-11-11 18:12:09 +00:00
fccview
01c87ab82f add backups and sort out uuid situation 2025-11-11 15:25:59 +00:00
79 changed files with 5251 additions and 1587 deletions

3
.gitignore vendored
View File

@@ -14,4 +14,5 @@ node_modules
.idea
tsconfig.tsbuildinfo
docker-compose.test.yml
/data
/data
claude.md

37
CONTRIBUTING.md Normal file
View File

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

239
README.md
View File

@@ -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>

View File

@@ -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}
/>
</>
);
};
};

View File

@@ -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>

View File

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

View File

@@ -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);
}
};

View File

@@ -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} />
) : (

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -15,6 +15,8 @@ interface CreateScriptModalProps {
content: string;
};
onFormChange: (updates: Partial<CreateScriptModalProps["form"]>) => void;
isDraft?: boolean;
onClearDraft?: () => void;
}
export const CreateScriptModal = ({
@@ -23,6 +25,8 @@ export const CreateScriptModal = ({
onSubmit,
form,
onFormChange,
isDraft,
onClearDraft,
}: CreateScriptModalProps) => {
return (
<ScriptModal
@@ -34,6 +38,8 @@ export const CreateScriptModal = ({
submitButtonIcon={<Plus className="h-4 w-4 mr-2" />}
form={form}
onFormChange={onFormChange}
isDraft={isDraft}
onClearDraft={onClearDraft}
/>
);
}

View File

@@ -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" />

View File

@@ -45,7 +45,7 @@ export const DeleteTaskModal = ({
<div className="flex items-start gap-2">
<Terminal className="h-3 w-3 text-muted-foreground mt-0.5 flex-shrink-0" />
<pre className="text-xs font-medium text-foreground break-words bg-muted/30 px-1 py-0.5 rounded border border-border/30 flex-1">
<pre className="max-w-full overflow-x-auto text-xs font-medium text-foreground break-words bg-muted/30 px-1 py-0.5 rounded border border-border/30 flex-1 hide-scrollbar">
{job.command}
</pre>
</div>
@@ -91,4 +91,4 @@ export const DeleteTaskModal = ({
</div>
</Modal>
);
}
};

View File

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

View File

@@ -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>

View File

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

View File

@@ -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>
);
}
};

View File

@@ -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>
);
}
};

View File

@@ -34,7 +34,9 @@ const categoryIcons = {
"Custom Scripts": Code,
};
export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) => {
export const BashSnippetHelper = ({
onInsertSnippet,
}: BashSnippetHelperProps) => {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
@@ -161,7 +163,7 @@ export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) =
</div>
)}
<div className="space-y-2 overflow-y-auto custom-scrollbar">
<div className="space-y-2 overflow-y-auto !pr-0 custom-scrollbar">
{filteredSnippets.map((snippet) => {
const Icon =
categoryIcons[snippet.category as keyof typeof categoryIcons] ||
@@ -243,4 +245,4 @@ export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) =
</div>
</div>
);
}
};

View File

@@ -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

View File

@@ -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>
);
}
};

View File

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

View File

@@ -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>
);
}
};

View File

@@ -52,8 +52,13 @@ export const UserSwitcher = ({
return (
<div className={`relative ${className}`}>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsOpen(!isOpen);
}}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
@@ -67,13 +72,17 @@ export const UserSwitcher = ({
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 max-h-48 overflow-y-auto">
{users.map((user) => (
<button
type="button"
key={user}
onClick={() => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUserChange(user);
setIsOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${selectedUser === user ? "bg-accent text-accent-foreground" : ""
}`}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${
selectedUser === user ? "bg-accent text-accent-foreground" : ""
}`}
>
{user}
</button>
@@ -82,4 +91,4 @@ export const UserSwitcher = ({
)}
</div>
);
}
};

View File

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

View File

@@ -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>
);
}
};

View File

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

View File

@@ -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 ''`;

View File

@@ -1,4 +0,0 @@
export const Locales = [
{ locale: "en", label: "English" },
{ locale: "it", label: "Italian" },
];

View File

@@ -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);

View File

@@ -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,
};
};

View File

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

View File

@@ -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,
};
}
};

View File

@@ -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 {

View File

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

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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
View File

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

View File

@@ -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 || "")
);
};

View File

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

View File

@@ -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;
}
};

View File

@@ -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();

View File

@@ -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}`;

View File

@@ -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;
};

View File

@@ -86,4 +86,4 @@ export const getScriptById = (
id: string
): Script | undefined => {
return scripts.find((script) => script.id === id);
}
};

View File

@@ -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
View File

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

View File

@@ -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);
}
};

View File

@@ -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;

View File

@@ -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:

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View 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 }
);
}
}

View File

@@ -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;
}

View File

@@ -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,
};
}
});

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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
```

View File

@@ -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

View File

@@ -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
View 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
View 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.

View File

@@ -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
View File

@@ -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.

View File

@@ -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
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

BIN
screenshots/home.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 729 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

BIN
screenshots/logs.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 292 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB

1330
yarn.lock
View File

File diff suppressed because it is too large Load Diff