mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-23 22:18:20 -05:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2dc0a3cb3 | ||
|
|
e40b0c0f63 | ||
|
|
79fd223416 | ||
|
|
eaca3fe44a | ||
|
|
e033caacf6 | ||
|
|
4beb7053f7 | ||
|
|
d26ce0e810 | ||
|
|
d6b6aff44e | ||
|
|
7954111d05 | ||
|
|
0ab3358e28 | ||
|
|
f53905c002 | ||
|
|
90775cac7c | ||
|
|
54188eb1c0 | ||
|
|
bf208e3075 | ||
|
|
a5fb5ff484 | ||
|
|
25190f3154 | ||
|
|
437bdbd81f | ||
|
|
d8ab3839c6 | ||
|
|
13fe6c5f3d | ||
|
|
9fb904d68a | ||
|
|
b95cd79239 | ||
|
|
7a4a22f8e9 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,4 +14,5 @@ node_modules
|
||||
.idea
|
||||
tsconfig.tsbuildinfo
|
||||
docker-compose.test.yml
|
||||
/data
|
||||
/data
|
||||
claude.md
|
||||
37
CONTRIBUTING.md
Normal file
37
CONTRIBUTING.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# How to contribute
|
||||
|
||||
Hi, it's amazing having a community willing to push new feature to the app, and I am VERY open to contributors pushing their idea, it's what makes open source amazing.
|
||||
|
||||
That said for the sake of sanity let's all follow the same structure:
|
||||
|
||||
- When creating a new branch, do off from the develop branch, this will always be ahead of main and it's what gets released
|
||||
- When creating a pull request, direct it back into develop, I'll then review it and merge it. Your code will end up in the next release that way and we all avoid conflicts!
|
||||
- Please bear with on reviews, it may take a bit of time for me to go through it all on top of life/work/hobbies :)
|
||||
|
||||
## Some best practices
|
||||
|
||||
### Code Quality
|
||||
|
||||
- Follow the existing code style and structure
|
||||
- Keep files modular and under 250-300 (split into smaller components if needed) lines unless it's a major server action, these can get intense I know
|
||||
- Avoid code duplication - reuse existing functions and UI components, don't hardcode html when a component already exists (e.g. <button> vs <Button>)
|
||||
- All imports should be at the top of the file unless it's for specific server actions
|
||||
- Avoid using `any`
|
||||
- Don't hardcode colors! Use the theme variables to make sure light/dark mode keep working well
|
||||
- Make sure the UI is consistent with the current one, look for spacing issues, consistent spacing really makes a difference
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- Keep PRs focused on a single feature or fix
|
||||
- Update documentation if your changes affect user-facing features
|
||||
- Test your changes locally before submitting
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch from `develop`
|
||||
3. Make your changes
|
||||
4. Test thoroughly
|
||||
5. Submit a pull request to `develop`
|
||||
|
||||
Thank you for contributing! <3
|
||||
225
README.md
225
README.md
@@ -13,7 +13,6 @@
|
||||
- [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)
|
||||
@@ -28,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.
|
||||
@@ -115,7 +114,7 @@ services:
|
||||
|
||||
## 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)**
|
||||
|
||||
@@ -131,7 +130,7 @@ services:
|
||||
|
||||
## Localization
|
||||
|
||||
`cr*nmaster` officially support [some languages](app/_transations) and allows you to create your custom translations locally on your own machine.
|
||||
`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)**
|
||||
|
||||
@@ -229,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 **[howto/API.md](howto/API.md)**
|
||||
|
||||
### Available Endpoints
|
||||
|
||||
- `GET /api/cronjobs` - List all cron jobs
|
||||
- `POST /api/cronjobs` - Create a new cron job
|
||||
- `GET /api/cronjobs/:id` - Get a specific cron job
|
||||
- `PATCH /api/cronjobs/:id` - Update a cron job
|
||||
- `DELETE /api/cronjobs/:id` - Delete a cron job
|
||||
- `POST /api/cronjobs/:id/execute` - Manually execute a job
|
||||
- `GET /api/scripts` - List all scripts
|
||||
- `POST /api/scripts` - Create a new script
|
||||
- `GET /api/system-stats` - Get system statistics
|
||||
- `GET /api/logs/stream?runId=xxx` - Stream job logs
|
||||
- `GET /api/events` - SSE stream for real-time updates
|
||||
**For detailed setup instructions, see **[howto/SSO.md](howto/SSO.md)**
|
||||
|
||||
<a id="usage"></a>
|
||||
|
||||
@@ -337,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
|
||||
|
||||
@@ -477,26 +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 from the `develop` branch
|
||||
3. Make your changes
|
||||
4. Submit a pull request to the `develop` branch
|
||||
|
||||
## Community shouts
|
||||
|
||||
I would like to thank the following members for raising issues and help test/debug them!
|
||||
@@ -537,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>
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
onNewTaskClick={() => setIsNewCronModalOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[55vh] overflow-y-auto dropdown-overflow-fix">
|
||||
<div className="space-y-3 max-h-[55vh] min-h-[55vh] overflow-y-auto">
|
||||
{loadedSettings ? (
|
||||
filteredJobs.map((job) =>
|
||||
minimalMode ? (
|
||||
|
||||
@@ -99,12 +99,12 @@ export const MinimalCronJobItem = ({
|
||||
},
|
||||
...(job.logsEnabled
|
||||
? [
|
||||
{
|
||||
label: t("cronjobs.viewLogs"),
|
||||
icon: <Code className="h-3 w-3" />,
|
||||
onClick: () => onViewLogs(job),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: t("cronjobs.viewLogs"),
|
||||
icon: <Code className="h-3 w-3" />,
|
||||
onClick: () => onViewLogs(job),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: job.paused
|
||||
@@ -139,12 +139,10 @@ export const MinimalCronJobItem = ({
|
||||
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" : ""
|
||||
}`}
|
||||
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">
|
||||
{/* Schedule display - minimal */}
|
||||
<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">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"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";
|
||||
@@ -12,26 +12,44 @@ import {
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/app/_components/GlobalComponents/Cards/Card";
|
||||
import { Lock, Eye, EyeOff, Shield, AlertTriangle } from "lucide-react";
|
||||
import { Lock, Eye, EyeOff, Shield, AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
interface LoginFormProps {
|
||||
hasPassword?: boolean;
|
||||
hasOIDC?: boolean;
|
||||
oidcAutoRedirect?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export const LoginForm = ({
|
||||
hasPassword = false,
|
||||
hasOIDC = false,
|
||||
oidcAutoRedirect = false,
|
||||
version,
|
||||
}: LoginFormProps) => {
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const errorParam = searchParams.get("error");
|
||||
|
||||
if (errorParam) {
|
||||
setError(decodeURIComponent(errorParam));
|
||||
return;
|
||||
}
|
||||
|
||||
if (oidcAutoRedirect && !hasPassword && hasOIDC) {
|
||||
setIsRedirecting(true);
|
||||
window.location.href = "/api/oidc/login";
|
||||
}
|
||||
}, [oidcAutoRedirect, hasPassword, hasOIDC, searchParams]);
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
@@ -65,6 +83,24 @@ export const LoginForm = ({
|
||||
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">
|
||||
|
||||
@@ -15,6 +15,8 @@ interface CreateScriptModalProps {
|
||||
content: string;
|
||||
};
|
||||
onFormChange: (updates: Partial<CreateScriptModalProps["form"]>) => void;
|
||||
isDraft?: boolean;
|
||||
onClearDraft?: () => void;
|
||||
}
|
||||
|
||||
export const CreateScriptModal = ({
|
||||
@@ -23,6 +25,8 @@ export const CreateScriptModal = ({
|
||||
onSubmit,
|
||||
form,
|
||||
onFormChange,
|
||||
isDraft,
|
||||
onClearDraft,
|
||||
}: CreateScriptModalProps) => {
|
||||
return (
|
||||
<ScriptModal
|
||||
@@ -34,6 +38,8 @@ export const CreateScriptModal = ({
|
||||
submitButtonIcon={<Plus className="h-4 w-4 mr-2" />}
|
||||
form={form}
|
||||
onFormChange={onFormChange}
|
||||
isDraft={isDraft}
|
||||
onClearDraft={onClearDraft}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,9 +64,10 @@ export const CreateTaskModal = ({
|
||||
}, [selectedScript]);
|
||||
|
||||
const handleScriptSelect = async (script: Script) => {
|
||||
const scriptPath = await getHostScriptPath(script.filename);
|
||||
onFormChange({
|
||||
selectedScriptId: script.id,
|
||||
command: await getHostScriptPath(script.filename),
|
||||
command: scriptPath,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -123,11 +124,10 @@ export const CreateTaskModal = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomCommand}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
!form.selectedScriptId
|
||||
className={`p-4 rounded-lg border-2 transition-all ${!form.selectedScriptId
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="h-5 w-5" />
|
||||
@@ -145,11 +145,10 @@ export const CreateTaskModal = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSelectScriptModalOpen(true)}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
form.selectedScriptId
|
||||
className={`p-4 rounded-lg border-2 transition-all ${form.selectedScriptId
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5" />
|
||||
|
||||
@@ -1,11 +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;
|
||||
@@ -15,6 +17,9 @@ interface LiveLogModalProps {
|
||||
jobComment?: string;
|
||||
}
|
||||
|
||||
const MAX_LINES_FULL_RENDER = 10000;
|
||||
const TAIL_LINES = 5000;
|
||||
|
||||
export const LiveLogModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -22,65 +27,125 @@ export const LiveLogModal = ({
|
||||
jobId,
|
||||
jobComment,
|
||||
}: LiveLogModalProps) => {
|
||||
const t = useTranslations();
|
||||
const [logContent, setLogContent] = useState<string>("");
|
||||
const [status, setStatus] = useState<"running" | "completed" | "failed">(
|
||||
"running"
|
||||
);
|
||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||
const [tailMode, setTailMode] = useState<boolean>(false);
|
||||
const [showSizeWarning, setShowSizeWarning] = useState<boolean>(false);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const { subscribe } = useSSEContext();
|
||||
const isPageVisible = usePageVisibility();
|
||||
const lastOffsetRef = useRef<number>(0);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [fileSize, setFileSize] = useState<number>(0);
|
||||
const [lineCount, setLineCount] = useState<number>(0);
|
||||
const [maxLines, setMaxLines] = useState<number>(500);
|
||||
const [totalLines, setTotalLines] = useState<number>(0);
|
||||
const [truncated, setTruncated] = useState<boolean>(false);
|
||||
const [showFullLog, setShowFullLog] = useState<boolean>(false);
|
||||
const [isJobComplete, setIsJobComplete] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
lastOffsetRef.current = 0;
|
||||
setLogContent("");
|
||||
setTailMode(false);
|
||||
setShowSizeWarning(false);
|
||||
setFileSize(0);
|
||||
setLineCount(0);
|
||||
setShowFullLog(false);
|
||||
setIsJobComplete(false);
|
||||
}
|
||||
}, [isOpen, runId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && runId && !isJobComplete) {
|
||||
lastOffsetRef.current = 0;
|
||||
setLogContent("");
|
||||
fetchLogs();
|
||||
}
|
||||
}, [maxLines]);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const url = `/api/logs/stream?runId=${runId}&offset=${lastOffsetRef.current}&maxLines=${maxLines}`;
|
||||
const response = await fetch(url, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.fileSize !== undefined) {
|
||||
lastOffsetRef.current = data.fileSize;
|
||||
setFileSize(data.fileSize);
|
||||
|
||||
if (data.fileSize > 10 * 1024 * 1024) {
|
||||
setShowSizeWarning(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.totalLines !== undefined) {
|
||||
setTotalLines(data.totalLines);
|
||||
}
|
||||
setLineCount(data.displayedLines || 0);
|
||||
|
||||
if (data.truncated !== undefined) {
|
||||
setTruncated(data.truncated);
|
||||
}
|
||||
|
||||
if (lastOffsetRef.current === 0 && data.content) {
|
||||
setLogContent(data.content);
|
||||
|
||||
if (data.truncated) {
|
||||
setTailMode(true);
|
||||
}
|
||||
} else if (data.newContent) {
|
||||
setLogContent((prev) => {
|
||||
const combined = prev + data.newContent;
|
||||
const lines = combined.split("\n");
|
||||
|
||||
if (lines.length > maxLines) {
|
||||
return lines.slice(-maxLines).join("\n");
|
||||
}
|
||||
|
||||
return combined;
|
||||
});
|
||||
}
|
||||
|
||||
const jobStatus = data.status || "running";
|
||||
setStatus(jobStatus);
|
||||
|
||||
if (jobStatus === "completed" || jobStatus === "failed") {
|
||||
setIsJobComplete(true);
|
||||
}
|
||||
|
||||
if (data.exitCode !== undefined) {
|
||||
setExitCode(data.exitCode);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
console.error("Failed to fetch logs:", error);
|
||||
}
|
||||
}
|
||||
}, [runId, maxLines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !runId || !isPageVisible) return;
|
||||
|
||||
lastOffsetRef.current = 0;
|
||||
setLogContent("");
|
||||
|
||||
const fetchLogs = 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}`;
|
||||
const response = await fetch(url, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.fileSize !== undefined) {
|
||||
lastOffsetRef.current = data.fileSize;
|
||||
}
|
||||
|
||||
if (lastOffsetRef.current === 0 && data.content) {
|
||||
setLogContent(data.content);
|
||||
} else if (data.newContent) {
|
||||
setLogContent((prev) => prev + data.newContent);
|
||||
}
|
||||
|
||||
setStatus(data.status || "running");
|
||||
|
||||
if (data.exitCode !== undefined) {
|
||||
setExitCode(data.exitCode);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
console.error("Failed to fetch logs:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogs();
|
||||
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
if (isPageVisible) {
|
||||
interval = setInterval(fetchLogs, 2000);
|
||||
if (isPageVisible && !isJobComplete) {
|
||||
interval = setInterval(fetchLogs, 3000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -91,7 +156,7 @@ export const LiveLogModal = ({
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [isOpen, runId, isPageVisible]);
|
||||
}, [isOpen, runId, isPageVisible, fetchLogs, isJobComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -101,53 +166,83 @@ export const LiveLogModal = ({
|
||||
setStatus("completed");
|
||||
setExitCode(event.data.exitCode);
|
||||
|
||||
fetch(`/api/logs/stream?runId=${runId}`)
|
||||
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}`)
|
||||
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>
|
||||
@@ -162,16 +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>
|
||||
|
||||
@@ -127,14 +127,12 @@ export const RestoreBackupModal = ({
|
||||
className="glass-card p-3 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Schedule */}
|
||||
<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>
|
||||
|
||||
{/* Command */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{commandCopied === backup.filename && (
|
||||
@@ -155,7 +153,6 @@ export const RestoreBackupModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User & Date */}
|
||||
<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" />
|
||||
@@ -167,7 +164,6 @@ export const RestoreBackupModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -198,7 +194,6 @@ export const RestoreBackupModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment (if present) */}
|
||||
{backup.job.comment && (
|
||||
<p className="text-xs text-muted-foreground italic mt-2 ml-0">
|
||||
{backup.job.comment}
|
||||
|
||||
@@ -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>
|
||||
@@ -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,19 +145,34 @@ export const ScriptModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border/30">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="btn-primary glow-primary">
|
||||
{submitButtonIcon}
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
<div className="flex justify-between items-center gap-3 pt-4 border-t border-border/30">
|
||||
<div>
|
||||
{isDraft && onClearDraft && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClearDraft}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t("scripts.clearDraft")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
>
|
||||
{t("scripts.close")}
|
||||
</Button>
|
||||
<Button type="submit" className="btn-primary glow-primary">
|
||||
{submitButtonIcon}
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { EditorState, Transaction } from "@codemirror/state";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { Terminal, Copy, Check } from "lucide-react";
|
||||
@@ -21,25 +22,94 @@ export const BashEditor = ({
|
||||
onChange,
|
||||
placeholder = "#!/bin/bash\n# Your bash script here\necho 'Hello World'",
|
||||
className = "",
|
||||
label = "Bash Script",
|
||||
label,
|
||||
}: BashEditorProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const editorViewRef = useRef<EditorView | null>(null);
|
||||
|
||||
const insertFourSpaces = ({
|
||||
state,
|
||||
dispatch,
|
||||
}: {
|
||||
state: EditorState;
|
||||
dispatch: (tr: Transaction) => void;
|
||||
}) => {
|
||||
if (state.selection.ranges.some((range) => !range.empty)) {
|
||||
const changes = state.selection.ranges
|
||||
.map((range) => {
|
||||
const fromLine = state.doc.lineAt(range.from).number;
|
||||
const toLine = state.doc.lineAt(range.to).number;
|
||||
const changes = [];
|
||||
for (let line = fromLine; line <= toLine; line++) {
|
||||
const lineObj = state.doc.line(line);
|
||||
changes.push({ from: lineObj.from, insert: " " });
|
||||
}
|
||||
return changes;
|
||||
})
|
||||
.flat();
|
||||
dispatch(state.update({ changes }));
|
||||
} else {
|
||||
dispatch(state.update(state.replaceSelection(" ")));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeFourSpaces = ({
|
||||
state,
|
||||
dispatch,
|
||||
}: {
|
||||
state: EditorState;
|
||||
dispatch: (tr: Transaction) => void;
|
||||
}) => {
|
||||
if (state.selection.ranges.some((range) => !range.empty)) {
|
||||
const changes = state.selection.ranges
|
||||
.map((range) => {
|
||||
const fromLine = state.doc.lineAt(range.from).number;
|
||||
const toLine = state.doc.lineAt(range.to).number;
|
||||
const changes = [];
|
||||
for (let line = fromLine; line <= toLine; line++) {
|
||||
const lineObj = state.doc.line(line);
|
||||
const indent = lineObj.text.match(/^ /);
|
||||
if (indent) {
|
||||
changes.push({ from: lineObj.from, to: lineObj.from + 4 });
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
})
|
||||
.flat();
|
||||
dispatch(state.update({ changes }));
|
||||
} else {
|
||||
const cursor = state.selection.main.head;
|
||||
const line = state.doc.lineAt(cursor);
|
||||
const beforeCursor = line.text.slice(0, cursor - line.from);
|
||||
const spacesToRemove = beforeCursor.match(/ {1,4}$/);
|
||||
if (spacesToRemove) {
|
||||
const removeCount = spacesToRemove[0].length;
|
||||
dispatch(
|
||||
state.update({
|
||||
changes: { from: cursor - removeCount, to: cursor },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
const bashLanguage = javascript({
|
||||
typescript: false,
|
||||
jsx: false,
|
||||
});
|
||||
const bashLanguage = StreamLanguage.define(shell);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value || placeholder,
|
||||
extensions: [
|
||||
bashLanguage,
|
||||
oneDark,
|
||||
keymap.of([
|
||||
{ key: "Tab", run: insertFourSpaces },
|
||||
{ key: "Shift-Tab", run: removeFourSpaces },
|
||||
]),
|
||||
EditorView.updateListener.of((update: any) => {
|
||||
if (update.docChanged) {
|
||||
onChange(update.state.doc.toString());
|
||||
@@ -115,6 +185,7 @@ export const BashEditor = ({
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
@@ -134,4 +205,4 @@ export const BashEditor = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,6 +65,7 @@ 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();
|
||||
@@ -72,6 +73,10 @@ export const SystemInfoCard = ({
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const updateSystemInfo = async () => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
@@ -88,13 +93,17 @@ export const SystemInfoCard = ({
|
||||
throw new Error("Failed to fetch system stats");
|
||||
}
|
||||
const freshData = await response.json();
|
||||
if (freshData === null) {
|
||||
setIsDisabled(true);
|
||||
return;
|
||||
}
|
||||
setSystemInfo(freshData);
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
console.error("Failed to update system info:", error);
|
||||
}
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
if (!abortControllerRef.current?.signal.aborted) {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +111,7 @@ export const SystemInfoCard = ({
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe((event: SSEEvent) => {
|
||||
if (event.type === "system-stats") {
|
||||
if (event.type === "system-stats" && event.data !== null) {
|
||||
setSystemInfo(event.data);
|
||||
}
|
||||
});
|
||||
@@ -129,16 +138,16 @@ export const SystemInfoCard = ({
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
const doUpdate = () => {
|
||||
if (!mounted || !isPageVisible) return;
|
||||
if (!mounted || !isPageVisible || isDisabled) return;
|
||||
updateTime();
|
||||
updateSystemInfo().finally(() => {
|
||||
if (mounted && isPageVisible) {
|
||||
if (mounted && isPageVisible && !isDisabled) {
|
||||
timeoutId = setTimeout(doUpdate, updateInterval);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isPageVisible) {
|
||||
if (isPageVisible && !isDisabled) {
|
||||
timeoutId = setTimeout(doUpdate, updateInterval);
|
||||
}
|
||||
|
||||
@@ -151,7 +160,7 @@ export const SystemInfoCard = ({
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [isPageVisible]);
|
||||
}, [isPageVisible, isDisabled]);
|
||||
|
||||
const quickStats = {
|
||||
cpu: systemInfo.cpu.usage,
|
||||
@@ -201,15 +210,15 @@ export const SystemInfoCard = ({
|
||||
},
|
||||
...(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",
|
||||
},
|
||||
]
|
||||
{
|
||||
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",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
@@ -226,12 +235,12 @@ export const SystemInfoCard = ({
|
||||
},
|
||||
...(systemInfo.network
|
||||
? [
|
||||
{
|
||||
label: t("sidebar.networkLatency"),
|
||||
value: `${systemInfo.network.latency}ms`,
|
||||
status: systemInfo.network.status,
|
||||
},
|
||||
]
|
||||
{
|
||||
label: t("sidebar.networkLatency"),
|
||||
value: `${systemInfo.network.latency}ms`,
|
||||
status: systemInfo.network.status,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
@@ -290,7 +299,7 @@ export const SystemInfoCard = ({
|
||||
{t("sidebar.statsUpdateEvery")}{" "}
|
||||
{Math.round(
|
||||
parseInt(process.env.NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL || "30000") /
|
||||
1000
|
||||
1000
|
||||
)}
|
||||
s • {t("sidebar.networkSpeedEstimatedFromLatency")}
|
||||
{isUpdating && (
|
||||
|
||||
@@ -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,8 +72,11 @@ 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);
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useRef, useEffect, ReactNode } from "react";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
|
||||
const DROPDOWN_HEIGHT = 200; // Approximate max height of dropdown
|
||||
const DROPDOWN_HEIGHT = 200;
|
||||
|
||||
interface DropdownMenuItem {
|
||||
label: string;
|
||||
@@ -36,13 +36,11 @@ export const DropdownMenu = ({
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open && triggerRef.current) {
|
||||
// Calculate if dropdown should be positioned above or below
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
|
||||
// Position above if there's not enough space below
|
||||
setPositionAbove(spaceBelow < DROPDOWN_HEIGHT && spaceAbove > spaceBelow);
|
||||
}
|
||||
setIsOpen(open);
|
||||
|
||||
@@ -10,7 +10,7 @@ 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;
|
||||
@@ -80,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 (
|
||||
|
||||
@@ -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 ''`;
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Hook to detect if the page/tab is currently visible to the user.
|
||||
* Returns true when the page is visible, false when hidden (user switched tabs).
|
||||
*
|
||||
* Use this to pause polling, SSE connections, or other resource-intensive
|
||||
* operations when the user is not actively viewing the page.
|
||||
*/
|
||||
|
||||
export function usePageVisibility(): boolean {
|
||||
const [isVisible, setIsVisible] = useState<boolean>(
|
||||
typeof document !== "undefined" ? !document.hidden : true
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -42,11 +42,7 @@ export const loadTranslationMessages = async (locale: string): Promise<any> => {
|
||||
|
||||
type TranslationFunction = (key: string) => string;
|
||||
|
||||
/**
|
||||
* Get a translation function for a given locale.
|
||||
* This function is server-only and should only be called from server components
|
||||
* or server actions.
|
||||
*/
|
||||
|
||||
export const getTranslations = async (
|
||||
locale: string = process.env.LOCALE || "en"
|
||||
): Promise<TranslationFunction> => {
|
||||
|
||||
@@ -87,7 +87,26 @@
|
||||
"both": "Both",
|
||||
"minimalMode": "Minimal Mode",
|
||||
"minimalModeDescription": "Show compact view with icons instead of full text",
|
||||
"applyFilters": "Apply Filters"
|
||||
"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",
|
||||
@@ -109,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",
|
||||
@@ -156,6 +179,8 @@
|
||||
"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."
|
||||
|
||||
@@ -86,7 +86,23 @@
|
||||
"both": "Entrambi",
|
||||
"minimalMode": "Modalità Minima",
|
||||
"minimalModeDescription": "Mostra vista compatta con icone invece del testo completo",
|
||||
"applyFilters": "Applica Filtri"
|
||||
"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",
|
||||
@@ -108,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",
|
||||
@@ -155,6 +175,8 @@
|
||||
"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."
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -251,7 +251,13 @@ export const deleteCronJob = async (id: string): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const updateCronJob = async (
|
||||
jobData: { id: string; schedule: string; command: string; comment?: string; user: string },
|
||||
jobData: {
|
||||
id: string;
|
||||
schedule: string;
|
||||
command: string;
|
||||
comment?: string;
|
||||
user: string;
|
||||
},
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = "",
|
||||
@@ -275,7 +281,12 @@ export const updateCronJob = async (
|
||||
|
||||
if (logsEnabled && !isWrapped) {
|
||||
const docker = await isDocker();
|
||||
finalCommand = await wrapCommandWithLogger(jobData.id, command, docker, comment);
|
||||
finalCommand = await wrapCommandWithLogger(
|
||||
jobData.id,
|
||||
command,
|
||||
docker,
|
||||
comment
|
||||
);
|
||||
} else if (!logsEnabled && isWrapped) {
|
||||
finalCommand = unwrapCommand(command);
|
||||
} else if (logsEnabled && isWrapped) {
|
||||
@@ -390,7 +401,14 @@ export const cleanupCrontab = async (): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const findJobIndex = (
|
||||
jobData: { id: string; schedule: string; command: string; comment?: string; user: string; paused?: boolean },
|
||||
jobData: {
|
||||
id: string;
|
||||
schedule: string;
|
||||
command: string;
|
||||
comment?: string;
|
||||
user: string;
|
||||
paused?: boolean;
|
||||
},
|
||||
lines: string[],
|
||||
user: string
|
||||
): number => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
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[],
|
||||
@@ -55,7 +70,11 @@ export const pauseJobInLines = (
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const commentText = trimmedLine.substring(1).trim();
|
||||
const { comment, logsEnabled } = parseCommentMetadata(commentText);
|
||||
const formattedComment = formatCommentWithMetadata(comment, logsEnabled, uuid);
|
||||
const formattedComment = formatCommentWithMetadata(
|
||||
comment,
|
||||
logsEnabled,
|
||||
uuid
|
||||
);
|
||||
const nextLine = lines[i + 1].trim();
|
||||
const pausedEntry = `# PAUSED: ${formattedComment}\n# ${nextLine}`;
|
||||
newCronEntries.push(pausedEntry);
|
||||
@@ -128,8 +147,14 @@ export const resumeJobInLines = (
|
||||
const { comment, logsEnabled } = parseCommentMetadata(commentText);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
const cronLine = lines[i + 1].trim().substring(2);
|
||||
const formattedComment = formatCommentWithMetadata(comment, logsEnabled, uuid);
|
||||
const resumedEntry = formattedComment ? `# ${formattedComment}\n${cronLine}` : cronLine;
|
||||
const formattedComment = formatCommentWithMetadata(
|
||||
comment,
|
||||
logsEnabled,
|
||||
uuid
|
||||
);
|
||||
const resumedEntry = formattedComment
|
||||
? `# ${formattedComment}\n${cronLine}`
|
||||
: cronLine;
|
||||
newCronEntries.push(resumedEntry);
|
||||
i += 2;
|
||||
} else {
|
||||
@@ -175,7 +200,9 @@ export const parseCommentMetadata = (
|
||||
let uuid: string | undefined;
|
||||
|
||||
if (parts.length > 1) {
|
||||
const firstPartIsMetadata = parts[0].match(/logsEnabled:\s*(true|false)/i) || parts[0].match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i);
|
||||
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 = "";
|
||||
@@ -186,9 +213,11 @@ export const parseCommentMetadata = (
|
||||
logsEnabled = logsMatch[1].toLowerCase() === "true";
|
||||
}
|
||||
|
||||
const uuidMatch = metadata.match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i);
|
||||
if (uuidMatch) {
|
||||
uuid = uuidMatch[1].toLowerCase();
|
||||
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] || "";
|
||||
@@ -199,14 +228,18 @@ export const parseCommentMetadata = (
|
||||
logsEnabled = logsMatch[1].toLowerCase() === "true";
|
||||
}
|
||||
|
||||
const uuidMatch = metadata.match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i);
|
||||
if (uuidMatch) {
|
||||
uuid = uuidMatch[1].toLowerCase();
|
||||
const uuidMatches = Array.from(
|
||||
metadata.matchAll(/id:\s*([a-z0-9]{8}|[a-z0-9]{4}-[a-z0-9]{4})/gi)
|
||||
);
|
||||
if (uuidMatches.length > 0) {
|
||||
uuid = uuidMatches[uuidMatches.length - 1][1].toLowerCase();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const logsMatch = commentText.match(/logsEnabled:\s*(true|false)/i);
|
||||
const uuidMatch = commentText.match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/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) {
|
||||
@@ -288,7 +321,8 @@ export const parseJobsFromLines = (
|
||||
const schedule = parts.slice(0, 5).join(" ");
|
||||
const command = parts.slice(5).join(" ");
|
||||
|
||||
const jobId = uuid || generateShortUUID();
|
||||
const jobId =
|
||||
uuid || generateStableJobId(schedule, command, user, comment, i);
|
||||
|
||||
jobs.push({
|
||||
id: jobId,
|
||||
@@ -317,7 +351,8 @@ export const parseJobsFromLines = (
|
||||
lines[i + 1].trim()
|
||||
) {
|
||||
const commentText = trimmedLine.substring(1).trim();
|
||||
const { comment, logsEnabled, uuid } = parseCommentMetadata(commentText);
|
||||
const { comment, logsEnabled, uuid } =
|
||||
parseCommentMetadata(commentText);
|
||||
currentComment = comment;
|
||||
currentLogsEnabled = logsEnabled;
|
||||
currentUuid = uuid;
|
||||
@@ -343,7 +378,9 @@ export const parseJobsFromLines = (
|
||||
}
|
||||
|
||||
if (schedule && command) {
|
||||
const jobId = currentUuid || generateShortUUID();
|
||||
const jobId =
|
||||
currentUuid ||
|
||||
generateStableJobId(schedule, command, user, currentComment, i);
|
||||
|
||||
jobs.push({
|
||||
id: jobId,
|
||||
@@ -545,7 +582,11 @@ export const updateJobInLines = (
|
||||
}
|
||||
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const formattedComment = formatCommentWithMetadata(comment, logsEnabled, uuid);
|
||||
const formattedComment = formatCommentWithMetadata(
|
||||
comment,
|
||||
logsEnabled,
|
||||
uuid
|
||||
);
|
||||
const newEntry = formattedComment
|
||||
? `# ${formattedComment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
|
||||
@@ -95,3 +95,62 @@ export const stopLogWatcher = () => {
|
||||
watcher = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const watchForLogFile = (
|
||||
runId: string,
|
||||
logFolderName: string,
|
||||
jobStartTime: Date,
|
||||
callback: (logFileName: string) => void
|
||||
): NodeJS.Timeout => {
|
||||
const logDir = path.join(LOGS_DIR, logFolderName);
|
||||
const startTime = jobStartTime.getTime();
|
||||
const maxAttempts = 30;
|
||||
let attempts = 0;
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
attempts++;
|
||||
|
||||
if (attempts > maxAttempts) {
|
||||
console.warn(`[LogWatcher] Timeout waiting for log file for ${runId}`);
|
||||
clearInterval(checkInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(logDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = readdirSync(logDir);
|
||||
const logFiles = files
|
||||
.filter((f) => f.endsWith(".log"))
|
||||
.map((f) => {
|
||||
const filePath = path.join(logDir, f);
|
||||
try {
|
||||
const stats = statSync(filePath);
|
||||
return {
|
||||
name: f,
|
||||
birthtime: stats.birthtime || stats.mtime,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((f): f is { name: string; birthtime: Date } => f !== null);
|
||||
|
||||
const matchingFile = logFiles.find((f) => {
|
||||
const fileTime = f.birthtime.getTime();
|
||||
return fileTime >= startTime - 5000 && fileTime <= startTime + 30000;
|
||||
});
|
||||
|
||||
if (matchingFile) {
|
||||
clearInterval(checkInterval);
|
||||
callback(matchingFile.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[LogWatcher] Error watching for log file ${runId}:`, error);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return checkInterval;
|
||||
};
|
||||
|
||||
@@ -86,4 +86,4 @@ export const getScriptById = (
|
||||
id: string
|
||||
): Script | undefined => {
|
||||
return scripts.find((script) => script.id === id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,3 +100,55 @@ export const extractJobIdFromWrappedCommand = (
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const cleanupOldLogFiles = async (
|
||||
jobId: string,
|
||||
maxFiles: number = 10
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { readdir, stat, unlink } = await import("fs/promises");
|
||||
const logFolderName = generateLogFolderName(jobId);
|
||||
const logDir = path.join(process.cwd(), "data", "logs", logFolderName);
|
||||
|
||||
try {
|
||||
await stat(logDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await readdir(logDir);
|
||||
const logFiles = files
|
||||
.filter((f) => f.endsWith(".log"))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: path.join(logDir, f),
|
||||
stats: null as any,
|
||||
}));
|
||||
|
||||
for (const file of logFiles) {
|
||||
try {
|
||||
file.stats = await stat(file.path);
|
||||
} catch (error) {
|
||||
console.error(`Error stat-ing log file ${file.path}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const validFiles = logFiles
|
||||
.filter((f) => f.stats)
|
||||
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime());
|
||||
|
||||
if (validFiles.length > maxFiles) {
|
||||
const filesToDelete = validFiles.slice(maxFiles);
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await unlink(file.path);
|
||||
console.log(`Cleaned up old log file: ${file.path}`);
|
||||
} catch (error) {
|
||||
console.error(`Error deleting log file ${file.path}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error cleaning up log files for job ${jobId}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,10 +3,6 @@ import { validateSession, getSessionCookieName } from "@/app/_utils/session-util
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Validate session for middleware
|
||||
* This runs in Node.js runtime so it can access the filesystem
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const cookieName = getSessionCookieName();
|
||||
const sessionId = request.cookies.get(cookieName)?.value;
|
||||
|
||||
@@ -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";
|
||||
@@ -17,6 +17,11 @@ export const GET = async (request: NextRequest) => {
|
||||
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(
|
||||
{ error: "runId parameter is required" },
|
||||
@@ -68,35 +73,154 @@ export const GET = async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const sortedFiles = files.sort().reverse();
|
||||
const latestLogFile = path.join(logDir, sortedFiles[0]);
|
||||
|
||||
const fullContent = await readFile(latestLogFile, "utf-8");
|
||||
const fileSize = Buffer.byteLength(fullContent, "utf-8");
|
||||
let latestLogFile: string | null = null;
|
||||
let latestStats: any = null;
|
||||
const jobStartTime = new Date(job.startTime);
|
||||
const TIME_TOLERANCE_MS = 5000;
|
||||
|
||||
let content = fullContent;
|
||||
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 && offset < fileSize) {
|
||||
newContent = fullContent.slice(offset);
|
||||
content = newContent;
|
||||
} else if (offset === 0) {
|
||||
content = fullContent;
|
||||
newContent = fullContent;
|
||||
} else if (offset >= fileSize) {
|
||||
content = "";
|
||||
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 ? fullContent : undefined,
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -107,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;
|
||||
@@ -139,7 +134,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Cyberpunk-inspired gradient background */
|
||||
@layer components {
|
||||
.hero-gradient {
|
||||
background: linear-gradient(135deg,
|
||||
@@ -171,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;
|
||||
}
|
||||
@@ -184,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;
|
||||
}
|
||||
@@ -201,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;
|
||||
}
|
||||
@@ -222,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;
|
||||
@@ -250,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;
|
||||
}
|
||||
@@ -259,7 +247,6 @@
|
||||
@apply opacity-100 visible;
|
||||
}
|
||||
|
||||
/* Responsive text utilities */
|
||||
.text-responsive {
|
||||
@apply text-sm sm:text-base lg:text-lg;
|
||||
}
|
||||
@@ -272,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;
|
||||
}
|
||||
@@ -305,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,
|
||||
@@ -321,7 +306,7 @@
|
||||
|
||||
@layer utilities {
|
||||
body.sidebar-collapsed main.lg\:ml-80 {
|
||||
margin-left: 4rem !important; /* 64px */
|
||||
margin-left: 4rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import path from "path";
|
||||
export default async function LoginPage() {
|
||||
const hasPassword = !!process.env.AUTH_PASSWORD;
|
||||
const hasOIDC = process.env.SSO_MODE === "oidc";
|
||||
const oidcAutoRedirect = process.env.OIDC_AUTO_REDIRECT === "true";
|
||||
|
||||
// Read package.json to get version
|
||||
const packageJsonPath = path.join(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||
const version = packageJson.version;
|
||||
@@ -20,6 +20,7 @@ export default async function LoginPage() {
|
||||
<LoginForm
|
||||
hasPassword={hasPassword}
|
||||
hasOIDC={hasOIDC}
|
||||
oidcAutoRedirect={oidcAutoRedirect}
|
||||
version={version}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -90,9 +90,11 @@ 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} />
|
||||
|
||||
227
howto/API.md
227
howto/API.md
@@ -106,6 +106,104 @@ curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/cronjobs/:id
|
||||
|
||||
Update a cron job.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `id` (string) - Cron job ID
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"schedule": "0 3 * * *",
|
||||
"command": "/usr/bin/echo updated",
|
||||
"comment": "Updated job",
|
||||
"logsEnabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Cron job updated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -X PATCH \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schedule":"0 3 * * *","command":"/usr/bin/echo updated"}' \
|
||||
https://your-cronmaster-url.com/api/cronjobs/fccview-0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/cronjobs/:id
|
||||
|
||||
Delete a cron job.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `id` (string) - Cron job ID
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Cron job deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -X DELETE \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
https://your-cronmaster-url.com/api/cronjobs/fccview-0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/cronjobs/:id/execute
|
||||
|
||||
Manually execute a cron job.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `id` (string) - Cron job ID
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `runInBackground` (boolean, optional) - Whether to run the job in background. Defaults to `true`.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"runId": "run-123",
|
||||
"message": "Job execution started"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
https://your-cronmaster-url.com/api/cronjobs/fccview-0/execute?runInBackground=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/scripts
|
||||
|
||||
List all scripts.
|
||||
@@ -196,6 +294,127 @@ curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
|
||||
---
|
||||
|
||||
### GET /api/logs/stream
|
||||
|
||||
Stream job execution logs.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `runId` (string, required) - The run ID of the job execution
|
||||
- `offset` (number, optional) - Byte offset for streaming new content. Defaults to `0`.
|
||||
- `maxLines` (number, optional) - Maximum lines to return. Defaults to `500`, min `100`, max `5000`.
|
||||
|
||||
**Note:** When `offset=0`, the endpoint only reads the last `maxLines` from the file for performance. This means `totalLines` is only returned when the file is small enough to read entirely (not truncated).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"content": "[log content]",
|
||||
"newContent": "[new log content since offset]",
|
||||
"logFile": "2025-11-10_14-30-00.log",
|
||||
"isComplete": false,
|
||||
"exitCode": null,
|
||||
"fileSize": 1024,
|
||||
"offset": 0,
|
||||
"totalLines": 50,
|
||||
"displayedLines": 50,
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response Fields:**
|
||||
|
||||
- `status` (string) - Job status: "running", "completed", or "failed"
|
||||
- `content` (string) - The log content to display
|
||||
- `newContent` (string) - New content since the last offset (for streaming)
|
||||
- `logFile` (string) - Name of the log file
|
||||
- `isComplete` (boolean) - Whether the job has completed
|
||||
- `exitCode` (number | null) - Exit code of the job (null if still running)
|
||||
- `fileSize` (number) - Total size of the log file in bytes
|
||||
- `offset` (number) - Current byte offset
|
||||
- `totalLines` (number | undefined) - Total number of lines in the file (only returned when file is small enough to read entirely)
|
||||
- `displayedLines` (number) - Number of lines being displayed
|
||||
- `truncated` (boolean) - Whether the content is truncated due to maxLines limit
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
"https://your-cronmaster-url.com/api/logs/stream?runId=run-123&offset=0&maxLines=500"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/system/wrapper-check
|
||||
|
||||
Check if the log wrapper script has been modified from the default.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"modified": false
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
https://your-cronmaster-url.com/api/system/wrapper-check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/oidc/login
|
||||
|
||||
Initiate OIDC (SSO) login flow. Redirects to the OIDC provider's authorization endpoint.
|
||||
|
||||
**Note:** This endpoint is only available when `SSO_MODE=oidc` is configured.
|
||||
|
||||
**Response:** HTTP 302 redirect to OIDC provider
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -L https://your-cronmaster-url.com/api/oidc/login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/oidc/callback
|
||||
|
||||
OIDC callback endpoint. Handles the authorization code from the OIDC provider and creates a session.
|
||||
|
||||
**Note:** This endpoint is typically called by the OIDC provider after authentication, not directly by clients.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `code` (string) - Authorization code from OIDC provider
|
||||
- `state` (string) - State parameter for CSRF protection
|
||||
|
||||
**Response:** HTTP 302 redirect to application root
|
||||
|
||||
---
|
||||
|
||||
### GET /api/oidc/logout
|
||||
|
||||
Initiate OIDC logout flow. Redirects to the OIDC provider's logout endpoint.
|
||||
|
||||
**Note:** This endpoint is only available when `SSO_MODE=oidc` is configured.
|
||||
|
||||
**Response:** HTTP 302 redirect to OIDC provider logout endpoint
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl -L https://your-cronmaster-url.com/api/oidc/logout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/auth/login
|
||||
|
||||
Login with password (alternative to API key).
|
||||
@@ -264,11 +483,3 @@ Logout and clear session (requires login first).
|
||||
"message": "Authentication required. Use session cookie or API key (Bearer token)."
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
For local testing I have made a node script that checks all available endpoints:
|
||||
|
||||
```bash
|
||||
AUTH_PASSWORD=your-password node test-api.js https://your-cronmaster-url.com
|
||||
```
|
||||
|
||||
@@ -56,6 +56,7 @@ Translation loading priority:
|
||||
| ----------------------------------- | ------- | -------------------------------------------------- |
|
||||
| `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
|
||||
|
||||
@@ -84,6 +85,7 @@ Translation loading priority:
|
||||
| `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
|
||||
@@ -134,7 +136,7 @@ services:
|
||||
- AUTH_PASSWORD=your_secure_password
|
||||
- HOST_CRONTAB_USER=root
|
||||
- APP_URL=https://cron.yourdomain.com
|
||||
- LOCALE=en # Can be any locale code, including custom ones
|
||||
- LOCALE=en
|
||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||
- LIVE_UPDATES=true
|
||||
- MAX_LOG_AGE_DAYS=30
|
||||
@@ -144,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
93
howto/LOGS.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Job Execution Logging
|
||||
|
||||
CronMaster includes an optional logging feature that captures detailed execution information for your cronjobs.
|
||||
|
||||
## How It Works
|
||||
|
||||
When you enable logging for a cronjob, CronMaster automatically wraps your command with a log wrapper script. This wrapper:
|
||||
|
||||
- Captures **stdout** and **stderr** output
|
||||
- Records the **exit code** of your command
|
||||
- Timestamps the **start and end** of execution
|
||||
- Calculates **execution duration**
|
||||
- Stores all this information in organized log files
|
||||
|
||||
## Enabling Logs
|
||||
|
||||
1. When creating or editing a cronjob, check the "Enable Logging" checkbox
|
||||
2. The wrapper is automatically added to your crontab entry
|
||||
3. Jobs run independently - they continue to work even if CronMaster is offline
|
||||
|
||||
## Log Storage
|
||||
|
||||
Logs are stored in the `./data/logs/` directory with descriptive folder names:
|
||||
|
||||
- If a job has a **description/comment**: `{sanitized-description}_{jobId}/`
|
||||
- If a job has **no description**: `{jobId}/`
|
||||
|
||||
Example structure:
|
||||
|
||||
```
|
||||
./data/logs/
|
||||
├── backup-database_root-0/
|
||||
│ ├── 2025-11-10_14-30-00.log
|
||||
│ ├── 2025-11-10_15-30-00.log
|
||||
│ └── 2025-11-10_16-30-00.log
|
||||
├── daily-cleanup_root-1/
|
||||
│ └── 2025-11-10_14-35-00.log
|
||||
├── root-2/ (no description provided)
|
||||
│ └── 2025-11-10_14-40-00.log
|
||||
```
|
||||
|
||||
**Note**: Folder names are sanitized to be filesystem-safe (lowercase, alphanumeric with hyphens, max 50 chars for the description part).
|
||||
|
||||
## Log Format
|
||||
|
||||
Each log file includes:
|
||||
|
||||
```
|
||||
--- [ JOB START ] ----------------------------------------------------
|
||||
Command : bash /app/scripts/backup.sh
|
||||
Timestamp : 2025-11-10 14:30:00
|
||||
Host : hostname
|
||||
User : root
|
||||
--- [ JOB OUTPUT ] ---------------------------------------------------
|
||||
|
||||
[command output here]
|
||||
|
||||
--- [ JOB SUMMARY ] --------------------------------------------------
|
||||
Timestamp : 2025-11-10 14:30:45
|
||||
Duration : 45s
|
||||
Exit Code : 0
|
||||
Status : SUCCESS
|
||||
--- [ JOB END ] ------------------------------------------------------
|
||||
```
|
||||
|
||||
## Automatic Cleanup
|
||||
|
||||
Logs are automatically cleaned up to prevent disk space issues:
|
||||
|
||||
- **Maximum logs per job**: 50 log files
|
||||
- **Maximum age**: 30 days
|
||||
- **Cleanup trigger**: When viewing logs or after manual execution
|
||||
- **Method**: Oldest logs are deleted first when limits are exceeded
|
||||
|
||||
## Docker Considerations
|
||||
|
||||
- Mount the `./data` directory to persist logs on the host
|
||||
- The wrapper script location: `./data/cron-log-wrapper.sh`. This will be generated automatically the first time you enable logging.
|
||||
|
||||
## Non-Docker Considerations
|
||||
|
||||
- Logs are stored at `./data/logs/` relative to the project directory
|
||||
- The codebase wrapper script location: `./app/_scripts/cron-log-wrapper.sh`
|
||||
- The running wrapper script location: `./data/cron-log-wrapper.sh`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Logging is **optional** and disabled by default
|
||||
- Jobs with logging enabled are marked with a blue "Logged" badge in the UI
|
||||
- Logs are captured for both scheduled runs and manual executions
|
||||
- Commands with file redirections (>, >>) may conflict with logging
|
||||
- The crontab stores the **wrapped command**, so jobs run independently of CronMaster
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cronjob-manager",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -13,6 +13,7 @@
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.8.5",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
@@ -31,7 +32,7 @@
|
||||
"jose": "^6.1.1",
|
||||
"lucide-react": "^0.294.0",
|
||||
"minimatch": "^10.0.3",
|
||||
"next": "14.0.4",
|
||||
"next": "14.2.35",
|
||||
"next-intl": "^4.4.0",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.2.1",
|
||||
|
||||
Reference in New Issue
Block a user