mirror of
https://github.com/fccview/cronmaster.git
synced 2026-04-18 04:58:59 -04:00
latest changes
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Terminal, Copy, Check, Edit3 } from "lucide-react";
|
||||
import { Terminal, Copy, Check } from "lucide-react";
|
||||
|
||||
interface BashEditorProps {
|
||||
value: string;
|
||||
@@ -22,8 +21,8 @@ export function BashEditor({
|
||||
className = "",
|
||||
label = "Bash Script",
|
||||
}: BashEditorProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
@@ -31,12 +30,19 @@ export function BashEditor({
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
// Sync scroll position between textarea and syntax highlighter
|
||||
const handleScroll = () => {
|
||||
if (textareaRef.current) {
|
||||
const scrollTop = textareaRef.current.scrollTop;
|
||||
const scrollLeft = textareaRef.current.scrollLeft;
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsEditing(false);
|
||||
const syntaxElement = textareaRef.current
|
||||
.nextElementSibling as HTMLElement;
|
||||
if (syntaxElement) {
|
||||
syntaxElement.scrollTop = scrollTop;
|
||||
syntaxElement.scrollLeft = scrollLeft;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const SyntaxHighlighterComponent = SyntaxHighlighter as any;
|
||||
@@ -66,22 +72,23 @@ export function BashEditor({
|
||||
)}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onScroll={handleScroll}
|
||||
placeholder={placeholder}
|
||||
className="w-full h-24 p-2 border border-border rounded bg-background text-foreground font-mono text-sm resize-none focus:outline-none focus:ring-1 focus:ring-primary/20 placeholder:text-shadow-none placeholder:shadow-none"
|
||||
className="w-full h-32 p-3 border border-border rounded bg-background text-foreground font-mono text-sm resize-none focus:outline-none focus:ring-1 focus:ring-primary/20 relative z-10"
|
||||
style={{
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
|
||||
lineHeight: "1.4",
|
||||
color: "transparent",
|
||||
caretColor: "hsl(var(--foreground))",
|
||||
background: "transparent",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 p-2 pointer-events-none overflow-auto"
|
||||
className="absolute inset-0 p-3 pointer-events-none overflow-auto"
|
||||
style={{
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
|
||||
|
||||
@@ -1,165 +1,215 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/Card';
|
||||
import { Button } from './ui/Button';
|
||||
import { Input } from './ui/Input';
|
||||
import { Plus, Clock, Terminal, MessageSquare, Sparkles } from 'lucide-react';
|
||||
import { createCronJob } from '@/app/_server/actions/cronjobs';
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Input } from "./ui/Input";
|
||||
import { Plus, Clock, Terminal, MessageSquare, Sparkles } from "lucide-react";
|
||||
import { createCronJob } from "@/app/_server/actions/cronjobs";
|
||||
import { useState } from "react";
|
||||
import { showToast } from "./ui/Toast";
|
||||
|
||||
const schedulePresets = [
|
||||
{ label: 'Every Minute', value: '* * * * *', description: 'Runs every minute' },
|
||||
{ label: 'Every Hour', value: '0 * * * *', description: 'Runs at the start of every hour' },
|
||||
{ label: 'Daily at Midnight', value: '0 0 * * *', description: 'Runs once per day at 12:00 AM' },
|
||||
{ label: 'Weekly on Sunday', value: '0 0 * * 0', description: 'Runs once per week on Sunday at 12:00 AM' },
|
||||
{ label: 'Monthly', value: '0 0 1 * *', description: 'Runs once per month on the 1st at 12:00 AM' },
|
||||
{ label: 'Every 5 Minutes', value: '*/5 * * * *', description: 'Runs every 5 minutes' },
|
||||
{ label: 'Every 30 Minutes', value: '*/30 * * * *', description: 'Runs every 30 minutes' },
|
||||
{ label: 'Weekdays Only', value: '0 9 * * 1-5', description: 'Runs weekdays at 9:00 AM' },
|
||||
{
|
||||
label: "Every Minute",
|
||||
value: "* * * * *",
|
||||
description: "Runs every minute",
|
||||
},
|
||||
{
|
||||
label: "Every Hour",
|
||||
value: "0 * * * *",
|
||||
description: "Runs at the start of every hour",
|
||||
},
|
||||
{
|
||||
label: "Daily at Midnight",
|
||||
value: "0 0 * * *",
|
||||
description: "Runs once per day at 12:00 AM",
|
||||
},
|
||||
{
|
||||
label: "Weekly on Sunday",
|
||||
value: "0 0 * * 0",
|
||||
description: "Runs once per week on Sunday at 12:00 AM",
|
||||
},
|
||||
{
|
||||
label: "Monthly",
|
||||
value: "0 0 1 * *",
|
||||
description: "Runs once per month on the 1st at 12:00 AM",
|
||||
},
|
||||
{
|
||||
label: "Every 5 Minutes",
|
||||
value: "*/5 * * * *",
|
||||
description: "Runs every 5 minutes",
|
||||
},
|
||||
{
|
||||
label: "Every 30 Minutes",
|
||||
value: "*/30 * * * *",
|
||||
description: "Runs every 30 minutes",
|
||||
},
|
||||
{
|
||||
label: "Weekdays Only",
|
||||
value: "0 9 * * 1-5",
|
||||
description: "Runs weekdays at 9:00 AM",
|
||||
},
|
||||
];
|
||||
|
||||
export function CronJobForm() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
schedule: '',
|
||||
command: '',
|
||||
comment: ''
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
schedule: "",
|
||||
command: "",
|
||||
comment: "",
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('schedule', formData.schedule);
|
||||
form.append('command', formData.command);
|
||||
form.append('comment', formData.comment);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("schedule", formData.schedule);
|
||||
form.append("command", formData.command);
|
||||
form.append("comment", formData.comment);
|
||||
|
||||
const result = await createCronJob(form);
|
||||
if (result.success) {
|
||||
setFormData({ schedule: '', command: '', comment: '' });
|
||||
alert('Cron job created successfully!');
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to create cron job');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
const result = await createCronJob(form);
|
||||
if (result.success) {
|
||||
showToast("success", "Cron job created successfully!");
|
||||
setFormData({ schedule: "", command: "", comment: "" });
|
||||
} else {
|
||||
showToast("error", "Failed to create cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to create cron job",
|
||||
"Please try again later."
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetClick = (preset: string) => {
|
||||
setFormData(prev => ({ ...prev, schedule: preset }));
|
||||
};
|
||||
const handlePresetClick = (preset: string) => {
|
||||
setFormData((prev) => ({ ...prev, schedule: preset }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 gradient-text">
|
||||
<Plus className="h-5 w-5" />
|
||||
Create New Cron Job
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Schedule Presets */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3">Quick Schedule Presets</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{schedulePresets.map((preset) => (
|
||||
<Button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset.value)}
|
||||
className="text-left h-auto p-3 hover:bg-primary/5 hover:border-primary/30 transition-all duration-200"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="font-medium text-sm">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono break-all">{preset.value}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{preset.description}</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
return (
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 gradient-text">
|
||||
<Plus className="h-5 w-5" />
|
||||
Create New Cron Job
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Schedule Presets */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3">
|
||||
Quick Schedule Presets
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{schedulePresets.map((preset) => (
|
||||
<Button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset.value)}
|
||||
className="text-left h-auto p-3 hover:bg-primary/5 hover:border-primary/30 transition-all duration-200"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="font-medium text-sm">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono break-all">
|
||||
{preset.value}
|
||||
</div>
|
||||
|
||||
{/* Custom Schedule */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
<Clock className="h-4 w-4 inline mr-2" />
|
||||
Schedule (Cron Expression)
|
||||
</label>
|
||||
<Input
|
||||
value={formData.schedule}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, schedule: e.target.value }))}
|
||||
placeholder="* * * * *"
|
||||
className="font-mono"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Format: minute hour day month weekday (e.g., "0 9 * * 1-5" for weekdays at 9 AM)
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{preset.description}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
<Terminal className="h-4 w-4 inline mr-2" />
|
||||
Command
|
||||
</label>
|
||||
<Input
|
||||
value={formData.command}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, command: e.target.value }))}
|
||||
placeholder="/usr/bin/your-command"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
The command to execute (use absolute paths for best results)
|
||||
</p>
|
||||
</div>
|
||||
{/* Custom Schedule */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
<Clock className="h-4 w-4 inline mr-2" />
|
||||
Schedule (Cron Expression)
|
||||
</label>
|
||||
<Input
|
||||
value={formData.schedule}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, schedule: e.target.value }))
|
||||
}
|
||||
placeholder="* * * * *"
|
||||
className="font-mono"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Format: minute hour day month weekday (e.g., "0 9 * * 1-5" for
|
||||
weekdays at 9 AM)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
<MessageSquare className="h-4 w-4 inline mr-2" />
|
||||
Comment (Optional)
|
||||
</label>
|
||||
<Input
|
||||
value={formData.comment}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, comment: e.target.value }))}
|
||||
placeholder="What does this job do? (e.g., 'Backup database')"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add a description to help you remember what this job does
|
||||
</p>
|
||||
</div>
|
||||
{/* Command */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
<Terminal className="h-4 w-4 inline mr-2" />
|
||||
Command
|
||||
</label>
|
||||
<Input
|
||||
value={formData.command}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, command: e.target.value }))
|
||||
}
|
||||
placeholder="/usr/bin/your-command"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
The command to execute (use absolute paths for best results)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="glow-primary min-w-[140px]"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Creating...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Create Job
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
{/* Comment */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
<MessageSquare className="h-4 w-4 inline mr-2" />
|
||||
Comment (Optional)
|
||||
</label>
|
||||
<Input
|
||||
value={formData.comment}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, comment: e.target.value }))
|
||||
}
|
||||
placeholder="What does this job do? (e.g., 'Backup database')"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add a description to help you remember what this job does
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="glow-primary min-w-[140px]"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Creating...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Create Job
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Trash2, Clock, Edit, Plus } from "lucide-react";
|
||||
import { Trash2, Clock, Edit, Plus, Files } from "lucide-react";
|
||||
import { CronJob } from "@/app/_utils/system";
|
||||
import {
|
||||
removeCronJob,
|
||||
editCronJob,
|
||||
createCronJob,
|
||||
cloneCronJob,
|
||||
} from "@/app/_server/actions/cronjobs";
|
||||
import { useState } from "react";
|
||||
import { CreateTaskModal } from "./modals/CreateTaskModal";
|
||||
import { EditTaskModal } from "./modals/EditTaskModal";
|
||||
import { DeleteTaskModal } from "./modals/DeleteTaskModal";
|
||||
import { CloneTaskModal } from "./modals/CloneTaskModal";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { showToast } from "./ui/Toast";
|
||||
|
||||
interface CronJobListProps {
|
||||
cronJobs: CronJob[];
|
||||
@@ -26,7 +29,10 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isNewCronModalOpen, setIsNewCronModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||||
const [jobToDelete, setJobToDelete] = useState<CronJob | null>(null);
|
||||
const [jobToClone, setJobToClone] = useState<CronJob | null>(null);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
schedule: "",
|
||||
command: "",
|
||||
@@ -43,11 +49,17 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
const result = await removeCronJob(id);
|
||||
if (!result.success) {
|
||||
alert(result.message);
|
||||
if (result.success) {
|
||||
showToast("success", "Cron job deleted successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to delete cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Failed to delete cron job");
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to delete cron job",
|
||||
"Please try again later."
|
||||
);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
@@ -55,11 +67,34 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClone = async (newComment: string) => {
|
||||
if (!jobToClone) return;
|
||||
|
||||
setIsCloning(true);
|
||||
try {
|
||||
const result = await cloneCronJob(jobToClone.id, newComment);
|
||||
if (result.success) {
|
||||
setIsCloneModalOpen(false);
|
||||
setJobToClone(null);
|
||||
showToast("success", "Cron job cloned successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to clone cron job", result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsCloning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (job: CronJob) => {
|
||||
setJobToDelete(job);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmClone = (job: CronJob) => {
|
||||
setJobToClone(job);
|
||||
setIsCloneModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (job: CronJob) => {
|
||||
setEditingJob(job);
|
||||
setEditForm({
|
||||
@@ -85,11 +120,16 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
if (result.success) {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingJob(null);
|
||||
showToast("success", "Cron job updated successfully");
|
||||
} else {
|
||||
alert(result.message);
|
||||
showToast("error", "Failed to update cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Failed to update cron job");
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to update cron job",
|
||||
"Please try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,6 +140,9 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
formData.append("schedule", newCronForm.schedule);
|
||||
formData.append("command", newCronForm.command);
|
||||
formData.append("comment", newCronForm.comment);
|
||||
if (newCronForm.selectedScriptId) {
|
||||
formData.append("selectedScriptId", newCronForm.selectedScriptId);
|
||||
}
|
||||
|
||||
const result = await createCronJob(formData);
|
||||
if (result.success) {
|
||||
@@ -110,11 +153,16 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
comment: "",
|
||||
selectedScriptId: null,
|
||||
});
|
||||
showToast("success", "Cron job created successfully");
|
||||
} else {
|
||||
alert(result.message);
|
||||
showToast("error", "Failed to create cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Failed to create cron job");
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to create cron job",
|
||||
"Please try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -211,15 +259,29 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
size="sm"
|
||||
onClick={() => handleEdit(job)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Edit cron job"
|
||||
aria-label="Edit cron job"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmClone(job)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Clone cron job"
|
||||
aria-label="Clone cron job"
|
||||
>
|
||||
<Files className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(job)}
|
||||
disabled={deletingId === job.id}
|
||||
className="btn-destructive h-8 px-3"
|
||||
title="Delete cron job"
|
||||
aria-label="Delete cron job"
|
||||
>
|
||||
{deletingId === job.id ? (
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
@@ -266,6 +328,14 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
}
|
||||
job={jobToDelete}
|
||||
/>
|
||||
|
||||
<CloneTaskModal
|
||||
cronJob={jobToClone}
|
||||
isOpen={isCloneModalOpen}
|
||||
onClose={() => setIsCloneModalOpen(false)}
|
||||
onConfirm={handleClone}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Modal } from "./ui/Modal";
|
||||
import { BashEditor } from "./BashEditor";
|
||||
import { BashSnippetHelper } from "./BashSnippetHelper";
|
||||
import { Plus, FileText, Edit, Trash2, Copy, Check } from "lucide-react";
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Copy,
|
||||
Copy as CopyIcon,
|
||||
CheckCircle,
|
||||
Files,
|
||||
} from "lucide-react";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import {
|
||||
createScript,
|
||||
updateScript,
|
||||
deleteScript,
|
||||
fetchScripts,
|
||||
type Script,
|
||||
cloneScript,
|
||||
getScriptContent,
|
||||
} from "@/app/_server/actions/scripts";
|
||||
import { CreateScriptModal } from "./modals/CreateScriptModal";
|
||||
import { EditScriptModal } from "./modals/EditScriptModal";
|
||||
import { DeleteScriptModal } from "./modals/DeleteScriptModal";
|
||||
import { CloneScriptModal } from "./modals/CloneScriptModal";
|
||||
import { showToast } from "./ui/Toast";
|
||||
|
||||
interface ScriptsManagerProps {
|
||||
scripts: Script[];
|
||||
@@ -25,90 +37,131 @@ export function ScriptsManager({
|
||||
const [scripts, setScripts] = useState<Script[]>(initialScripts);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingScript, setEditingScript] = useState<Script | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||||
const [selectedScript, setSelectedScript] = useState<Script | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [newScript, setNewScript] = useState({
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
|
||||
// Form state for create modal
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
content: "#!/bin/bash\n# Your script here\necho 'Hello World'",
|
||||
});
|
||||
|
||||
// Refresh scripts when initialScripts changes
|
||||
useEffect(() => {
|
||||
setScripts(initialScripts);
|
||||
}, [initialScripts]);
|
||||
// Form state for edit modal
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
content: "",
|
||||
});
|
||||
|
||||
const refreshScripts = async () => {
|
||||
const freshScripts = await fetchScripts();
|
||||
setScripts(freshScripts);
|
||||
try {
|
||||
const { fetchScripts } = await import("@/app/_server/actions/scripts");
|
||||
const freshScripts = await fetchScripts();
|
||||
setScripts(freshScripts);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh scripts:", error);
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to refresh scripts",
|
||||
"Please try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("name", newScript.name);
|
||||
formData.append("description", newScript.description);
|
||||
formData.append("content", newScript.content);
|
||||
|
||||
const handleCreate = async (formData: FormData) => {
|
||||
const result = await createScript(formData);
|
||||
if (result.success) {
|
||||
setIsCreateModalOpen(false);
|
||||
setNewScript({
|
||||
name: "",
|
||||
description: "",
|
||||
content: "#!/bin/bash\n# Your script here\necho 'Hello World'",
|
||||
});
|
||||
await refreshScripts();
|
||||
setIsCreateModalOpen(false);
|
||||
showToast("success", "Script created successfully");
|
||||
} else {
|
||||
alert(result.message);
|
||||
showToast("error", "Failed to create script", result.message);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleEdit = (script: Script) => {
|
||||
setEditingScript(script);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editingScript) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("id", editingScript.id);
|
||||
formData.append("name", editingScript.name);
|
||||
formData.append("description", editingScript.description);
|
||||
formData.append("content", editingScript.content);
|
||||
|
||||
const handleEdit = async (formData: FormData) => {
|
||||
const result = await updateScript(formData);
|
||||
if (result.success) {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingScript(null);
|
||||
await refreshScripts();
|
||||
setIsEditModalOpen(false);
|
||||
setSelectedScript(null);
|
||||
showToast("success", "Script updated successfully");
|
||||
} else {
|
||||
alert(result.message);
|
||||
showToast("error", "Failed to update script", result.message);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedScript) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteScript(selectedScript.id);
|
||||
if (result.success) {
|
||||
await refreshScripts();
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedScript(null);
|
||||
showToast("success", "Script deleted successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to delete script", result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm("Are you sure you want to delete this script?")) {
|
||||
const result = await deleteScript(id);
|
||||
const handleClone = async (newName: string) => {
|
||||
if (!selectedScript) return;
|
||||
|
||||
setIsCloning(true);
|
||||
try {
|
||||
const result = await cloneScript(selectedScript.id, newName);
|
||||
if (result.success) {
|
||||
await refreshScripts();
|
||||
setIsCloneModalOpen(false);
|
||||
setSelectedScript(null);
|
||||
showToast("success", "Script cloned successfully");
|
||||
} else {
|
||||
alert(result.message);
|
||||
showToast("error", "Failed to clone script", result.message);
|
||||
}
|
||||
} finally {
|
||||
setIsCloning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (script: Script) => {
|
||||
await navigator.clipboard.writeText(script.content);
|
||||
setCopiedId(script.id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
try {
|
||||
const content = await getScriptContent(script.filename);
|
||||
|
||||
const handleInsertSnippet = (snippet: string) => {
|
||||
if (isCreateModalOpen) {
|
||||
setNewScript((prev) => ({ ...prev, content: snippet }));
|
||||
} else if (isEditModalOpen && editingScript) {
|
||||
setEditingScript((prev) => (prev ? { ...prev, content: snippet } : null));
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(content);
|
||||
} else {
|
||||
// Fallback for non-secure contexts or when clipboard API is not available
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = content;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
textArea.remove();
|
||||
}
|
||||
|
||||
setCopiedId(script.id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
showToast("success", "Script content copied to clipboard");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy script content:", error);
|
||||
showToast("error", "Failed to copy script content");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,9 +236,9 @@ export function ScriptsManager({
|
||||
{script.description}
|
||||
</p>
|
||||
)}
|
||||
<pre className="text-xs bg-muted/30 p-2 rounded border border-border/30 overflow-x-auto max-h-20">
|
||||
{script.content.split("\n")[0]}...
|
||||
</pre>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
File: {script.filename}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -195,26 +248,60 @@ export function ScriptsManager({
|
||||
size="sm"
|
||||
onClick={() => handleCopy(script)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Copy script content to clipboard"
|
||||
aria-label="Copy script content to clipboard"
|
||||
>
|
||||
{copiedId === script.id ? (
|
||||
<Check className="h-3 w-3" />
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
<CopyIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(script)}
|
||||
onClick={() => {
|
||||
setSelectedScript(script);
|
||||
setIsCloneModalOpen(true);
|
||||
}}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Clone script"
|
||||
aria-label="Clone script"
|
||||
>
|
||||
<Files className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setSelectedScript(script);
|
||||
// Load script content
|
||||
const content = await getScriptContent(
|
||||
script.filename
|
||||
);
|
||||
setEditForm({
|
||||
name: script.name,
|
||||
description: script.description,
|
||||
content: content,
|
||||
});
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Edit script"
|
||||
aria-label="Edit script"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(script.id)}
|
||||
onClick={() => {
|
||||
setSelectedScript(script);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
className="btn-destructive h-8 px-3"
|
||||
title="Delete script"
|
||||
aria-label="Delete script"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -227,172 +314,51 @@ export function ScriptsManager({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Script Modal */}
|
||||
<Modal
|
||||
<CreateScriptModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
title="Create New Script"
|
||||
size="xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Script Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newScript.name}
|
||||
onChange={(e) =>
|
||||
setNewScript((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="My Script"
|
||||
className="w-full p-2 border border-border rounded bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newScript.description}
|
||||
onChange={(e) =>
|
||||
setNewScript((prev) => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="What does this script do?"
|
||||
className="w-full p-2 border border-border rounded bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
onSubmit={handleCreate}
|
||||
form={createForm}
|
||||
onFormChange={(updates) =>
|
||||
setCreateForm((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Script Content
|
||||
</label>
|
||||
<BashEditor
|
||||
value={newScript.content}
|
||||
onChange={(value) =>
|
||||
setNewScript((prev) => ({ ...prev, content: value }))
|
||||
}
|
||||
placeholder="#!/bin/bash # Your script here echo 'Hello World'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<BashSnippetHelper onInsertSnippet={handleInsertSnippet} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-border/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateModalOpen(false)}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
className="btn-primary glow-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Script
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Script Modal */}
|
||||
<Modal
|
||||
<EditScriptModal
|
||||
script={selectedScript}
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => setIsEditModalOpen(false)}
|
||||
title="Edit Script"
|
||||
size="xl"
|
||||
>
|
||||
{editingScript && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Script Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingScript.name}
|
||||
onChange={(e) =>
|
||||
setEditingScript((prev) =>
|
||||
prev ? { ...prev, name: e.target.value } : null
|
||||
)
|
||||
}
|
||||
placeholder="My Script"
|
||||
className="w-full p-2 border border-border rounded bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingScript.description}
|
||||
onChange={(e) =>
|
||||
setEditingScript((prev) =>
|
||||
prev ? { ...prev, description: e.target.value } : null
|
||||
)
|
||||
}
|
||||
placeholder="What does this script do?"
|
||||
className="w-full p-2 border border-border rounded bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setSelectedScript(null);
|
||||
}}
|
||||
onSubmit={handleEdit}
|
||||
form={editForm}
|
||||
onFormChange={(updates) =>
|
||||
setEditForm((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Script Content
|
||||
</label>
|
||||
<BashEditor
|
||||
value={editingScript.content}
|
||||
onChange={(value) =>
|
||||
setEditingScript((prev) =>
|
||||
prev ? { ...prev, content: value } : null
|
||||
)
|
||||
}
|
||||
placeholder="#!/bin/bash # Your script here echo 'Hello World'"
|
||||
/>
|
||||
</div>
|
||||
<DeleteScriptModal
|
||||
script={selectedScript}
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedScript(null);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<BashSnippetHelper onInsertSnippet={handleInsertSnippet} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-border/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpdate}
|
||||
className="btn-primary glow-primary"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Update Script
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<CloneScriptModal
|
||||
script={selectedScript}
|
||||
isOpen={isCloneModalOpen}
|
||||
onClose={() => {
|
||||
setIsCloneModalOpen(false);
|
||||
setSelectedScript(null);
|
||||
}}
|
||||
onConfirm={handleClone}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
66
app/_components/TabbedInterface.tsx
Normal file
66
app/_components/TabbedInterface.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CronJobList } from "./CronJobList";
|
||||
import { ScriptsManager } from "./ScriptsManager";
|
||||
import { CronJob } from "@/app/_utils/system";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { Clock, FileText } from "lucide-react";
|
||||
|
||||
interface TabbedInterfaceProps {
|
||||
cronJobs: CronJob[];
|
||||
scripts: Script[];
|
||||
}
|
||||
|
||||
export function TabbedInterface({ cronJobs, scripts }: TabbedInterfaceProps) {
|
||||
const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">(
|
||||
"cronjobs"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-background/80 backdrop-blur-md border border-border/50 rounded-lg p-1 glass-card">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab("cronjobs")}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${
|
||||
activeTab === "cronjobs"
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
Cron Jobs
|
||||
<span className="ml-1 text-xs bg-primary/20 text-primary px-2 py-0.5 rounded-full font-medium">
|
||||
{cronJobs.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("scripts")}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${
|
||||
activeTab === "scripts"
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Scripts
|
||||
<span className="ml-1 text-xs bg-primary/20 text-primary px-2 py-0.5 rounded-full font-medium">
|
||||
{scripts.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-[400px]">
|
||||
{activeTab === "cronjobs" ? (
|
||||
<CronJobList cronJobs={cronJobs} scripts={scripts} />
|
||||
) : (
|
||||
<ScriptsManager scripts={scripts} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
app/_components/modals/CloneScriptModal.tsx
Normal file
101
app/_components/modals/CloneScriptModal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, FileText } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { Input } from "../ui/Input";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
|
||||
interface CloneScriptModalProps {
|
||||
script: Script | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (newName: string) => void;
|
||||
isCloning: boolean;
|
||||
}
|
||||
|
||||
export function CloneScriptModal({
|
||||
script,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isCloning,
|
||||
}: CloneScriptModalProps) {
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newName.trim()) {
|
||||
onConfirm(newName.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Clone Script" size="md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-medium text-foreground mb-2">{script.name}</h4>
|
||||
{script.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{script.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
File: {script.filename}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="newName"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
New Script Name
|
||||
</label>
|
||||
<Input
|
||||
id="newName"
|
||||
type="text"
|
||||
placeholder="Enter new script name..."
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
disabled={isCloning}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isCloning}
|
||||
className="flex-1 btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCloning || !newName.trim()}
|
||||
className="flex-1 btn-primary"
|
||||
>
|
||||
{isCloning ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Cloning...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Clone Script
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
101
app/_components/modals/CloneTaskModal.tsx
Normal file
101
app/_components/modals/CloneTaskModal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Clock } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { Input } from "../ui/Input";
|
||||
import { type CronJob } from "@/app/_utils/system";
|
||||
|
||||
interface CloneTaskModalProps {
|
||||
cronJob: CronJob | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (newComment: string) => void;
|
||||
isCloning: boolean;
|
||||
}
|
||||
|
||||
export function CloneTaskModal({
|
||||
cronJob,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isCloning,
|
||||
}: CloneTaskModalProps) {
|
||||
const [newComment, setNewComment] = useState("");
|
||||
|
||||
if (!isOpen || !cronJob) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newComment.trim()) {
|
||||
onConfirm(newComment.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Clone Cron Job" size="md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-medium text-foreground mb-2">
|
||||
{cronJob.comment}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Schedule: {cronJob.schedule}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Command: {cronJob.command}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="newComment"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
New Comment
|
||||
</label>
|
||||
<Input
|
||||
id="newComment"
|
||||
type="text"
|
||||
placeholder="Enter new comment..."
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
disabled={isCloning}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isCloning}
|
||||
className="flex-1 btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCloning || !newComment.trim()}
|
||||
className="flex-1 btn-primary"
|
||||
>
|
||||
{isCloning ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Cloning...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Clone Cron Job
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
115
app/_components/modals/CreateScriptModal.tsx
Normal file
115
app/_components/modals/CreateScriptModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { BashEditor } from "../BashEditor";
|
||||
import { BashSnippetHelper } from "../BashSnippetHelper";
|
||||
import { Plus } from "lucide-react";
|
||||
import { showToast } from "../ui/Toast";
|
||||
|
||||
interface CreateScriptModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
formData: FormData
|
||||
) => Promise<{ success: boolean; message: string }>;
|
||||
form: {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
};
|
||||
onFormChange: (updates: Partial<CreateScriptModalProps["form"]>) => void;
|
||||
}
|
||||
|
||||
export function CreateScriptModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
form,
|
||||
onFormChange,
|
||||
}: CreateScriptModalProps) {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData();
|
||||
formData.append("name", form.name);
|
||||
formData.append("description", form.description);
|
||||
formData.append("content", form.content);
|
||||
|
||||
const result = await onSubmit(formData);
|
||||
if (result.success) {
|
||||
onClose();
|
||||
} else {
|
||||
showToast("error", "Failed to create script", result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInsertSnippet = (snippet: string) => {
|
||||
onFormChange({ content: snippet });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Create New Script"
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Script Name
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ name: e.target.value })}
|
||||
placeholder="My Script"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
value={form.description}
|
||||
onChange={(e) => onFormChange({ description: e.target.value })}
|
||||
placeholder="What does this script do?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Script Content
|
||||
</label>
|
||||
<BashEditor
|
||||
value={form.content}
|
||||
onChange={(value) => onFormChange({ content: value })}
|
||||
placeholder="#!/bin/bash # Your script here echo 'Hello World'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<BashSnippetHelper onInsertSnippet={handleInsertSnippet} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-border/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="btn-primary glow-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Script
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { CronExpressionHelper } from "../CronExpressionHelper";
|
||||
import { Plus, Terminal, FileText } from "lucide-react";
|
||||
import { getScriptContent } from "@/app/_server/actions/scripts";
|
||||
import { getHostScriptPath } from "@/app/_utils/scripts";
|
||||
|
||||
interface Script {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface CreateTaskModalProps {
|
||||
@@ -36,12 +39,28 @@ export function CreateTaskModal({
|
||||
form,
|
||||
onFormChange,
|
||||
}: CreateTaskModalProps) {
|
||||
const [selectedScriptContent, setSelectedScriptContent] =
|
||||
useState<string>("");
|
||||
const selectedScript = scripts.find((s) => s.id === form.selectedScriptId);
|
||||
|
||||
// Load script content when script is selected
|
||||
useEffect(() => {
|
||||
const loadScriptContent = async () => {
|
||||
if (selectedScript) {
|
||||
const content = await getScriptContent(selectedScript.filename);
|
||||
setSelectedScriptContent(content);
|
||||
} else {
|
||||
setSelectedScriptContent("");
|
||||
}
|
||||
};
|
||||
|
||||
loadScriptContent();
|
||||
}, [selectedScript]);
|
||||
|
||||
const handleScriptSelect = (script: Script) => {
|
||||
onFormChange({
|
||||
selectedScriptId: script.id,
|
||||
command: script.content,
|
||||
command: getHostScriptPath(script.filename),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -153,7 +172,7 @@ export function CreateTaskModal({
|
||||
{/* Command Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{form.selectedScriptId ? "Script Content" : "Command"}
|
||||
Command
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
@@ -161,7 +180,7 @@ export function CreateTaskModal({
|
||||
onChange={(e) => onFormChange({ command: e.target.value })}
|
||||
placeholder={
|
||||
form.selectedScriptId
|
||||
? "Script content will appear here..."
|
||||
? "/app/scripts/script_name.sh"
|
||||
: "/usr/bin/command"
|
||||
}
|
||||
className="w-full h-24 p-2 border border-border rounded bg-background text-foreground font-mono text-sm resize-none focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
@@ -174,12 +193,25 @@ export function CreateTaskModal({
|
||||
</div>
|
||||
{form.selectedScriptId && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Script content is read-only. Edit the script in the Scripts
|
||||
Library.
|
||||
Script path is read-only. Edit the script in the Scripts Library.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Script Content Preview */}
|
||||
{form.selectedScriptId && selectedScriptContent && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Script Content Preview
|
||||
</label>
|
||||
<div className="bg-muted/30 p-3 rounded border border-border/30 max-h-32 overflow-auto">
|
||||
<pre className="text-xs font-mono text-foreground whitespace-pre-wrap">
|
||||
{selectedScriptContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
|
||||
106
app/_components/modals/DeleteScriptModal.tsx
Normal file
106
app/_components/modals/DeleteScriptModal.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { FileText, AlertCircle, Trash2 } from "lucide-react";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
|
||||
interface DeleteScriptModalProps {
|
||||
script: Script | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export function DeleteScriptModal({
|
||||
script,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isDeleting,
|
||||
}: DeleteScriptModalProps) {
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Delete Script" size="sm">
|
||||
<div className="space-y-3">
|
||||
{/* Script Preview */}
|
||||
<div className="bg-muted/30 rounded p-2 border border-border/50">
|
||||
<div className="space-y-1">
|
||||
{/* Name */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{script.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{script.description && (
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="h-3 w-3 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-muted-foreground break-words italic">
|
||||
{script.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filename */}
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="h-3 w-3 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<code className="text-xs font-mono bg-muted/30 px-1 py-0.5 rounded border border-border/30">
|
||||
{script.filename}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-destructive/5 border border-destructive/20 rounded p-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-destructive mb-0.5">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The script will be permanently removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
className="btn-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Script
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
118
app/_components/modals/EditScriptModal.tsx
Normal file
118
app/_components/modals/EditScriptModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { BashEditor } from "../BashEditor";
|
||||
import { BashSnippetHelper } from "../BashSnippetHelper";
|
||||
import { Edit } from "lucide-react";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { showToast } from "../ui/Toast";
|
||||
|
||||
interface EditScriptModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
formData: FormData
|
||||
) => Promise<{ success: boolean; message: string }>;
|
||||
script: Script | null;
|
||||
form: {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
};
|
||||
onFormChange: (updates: Partial<EditScriptModalProps["form"]>) => void;
|
||||
}
|
||||
|
||||
export function EditScriptModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
script,
|
||||
form,
|
||||
onFormChange,
|
||||
}: EditScriptModalProps) {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!script) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("id", script.id);
|
||||
formData.append("name", form.name);
|
||||
formData.append("description", form.description);
|
||||
formData.append("content", form.content);
|
||||
|
||||
const result = await onSubmit(formData);
|
||||
if (result.success) {
|
||||
onClose();
|
||||
} else {
|
||||
showToast("error", "Failed to update script", result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInsertSnippet = (snippet: string) => {
|
||||
onFormChange({ content: snippet });
|
||||
};
|
||||
|
||||
if (!script) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Edit Script" size="xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Script Name
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ name: e.target.value })}
|
||||
placeholder="My Script"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
value={form.description}
|
||||
onChange={(e) => onFormChange({ description: e.target.value })}
|
||||
placeholder="What does this script do?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Script Content
|
||||
</label>
|
||||
<BashEditor
|
||||
value={form.content}
|
||||
onChange={(value) => onFormChange({ content: value })}
|
||||
placeholder="#!/bin/bash # Your script here echo 'Hello World'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<BashSnippetHelper onInsertSnippet={handleInsertSnippet} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-border/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="btn-primary glow-primary">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Update Script
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
117
app/_components/ui/Toast.tsx
Normal file
117
app/_components/ui/Toast.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import { cn } from "@/app/_utils/cn";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: "success" | "error" | "info" | "warning";
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toast: Toast;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const toastIcons = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
};
|
||||
|
||||
const toastStyles = {
|
||||
success:
|
||||
"border-green-500/20 bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
error: "border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
info: "border-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
warning:
|
||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
|
||||
};
|
||||
|
||||
export function Toast({ toast, onRemove }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const Icon = toastIcons[toast.type];
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onRemove(toast.id), 300);
|
||||
}, toast.duration || 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, toast.duration, onRemove]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-lg border backdrop-blur-md transition-all duration-300 ease-in-out",
|
||||
toastStyles[toast.type],
|
||||
isVisible ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm">{toast.title}</h4>
|
||||
{toast.message && (
|
||||
<p className="text-sm opacity-90 mt-1">{toast.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onRemove(toast.id), 300);
|
||||
}}
|
||||
className="flex-shrink-0 p-1 rounded-md hover:bg-black/10 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = (toast: Omit<Toast, "id">) => {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
setToasts((prev) => [...prev, { ...toast, id }]);
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
// Expose addToast globally
|
||||
useEffect(() => {
|
||||
(window as any).showToast = addToast;
|
||||
return () => {
|
||||
delete (window as any).showToast;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<Toast key={toast.id} toast={toast} onRemove={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to show toasts
|
||||
export function showToast(
|
||||
type: Toast["type"],
|
||||
title: string,
|
||||
message?: string,
|
||||
duration?: number
|
||||
) {
|
||||
if (typeof window !== "undefined" && (window as any).showToast) {
|
||||
(window as any).showToast({ type, title, message, duration });
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
type SystemInfo,
|
||||
} from "@/app/_utils/system";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { getScriptPath } from "@/app/_utils/scripts";
|
||||
|
||||
export async function fetchCronJobs(): Promise<CronJob[]> {
|
||||
try {
|
||||
@@ -28,8 +27,8 @@ export async function fetchSystemInfo(): Promise<SystemInfo> {
|
||||
} catch (error) {
|
||||
console.error("Error fetching system info:", error);
|
||||
return {
|
||||
platform: "Unknown",
|
||||
hostname: "Unknown",
|
||||
platform: "Unknown",
|
||||
ip: "Unknown",
|
||||
uptime: "Unknown",
|
||||
memory: {
|
||||
@@ -39,11 +38,19 @@ export async function fetchSystemInfo(): Promise<SystemInfo> {
|
||||
usage: 0,
|
||||
status: "Unknown",
|
||||
},
|
||||
cpu: { model: "Unknown", cores: 0, usage: 0, status: "Unknown" },
|
||||
gpu: { model: "Unknown", status: "Unknown" },
|
||||
cpu: {
|
||||
model: "Unknown",
|
||||
cores: 0,
|
||||
usage: 0,
|
||||
status: "Unknown",
|
||||
},
|
||||
gpu: {
|
||||
model: "Unknown",
|
||||
status: "Unknown",
|
||||
},
|
||||
network: {
|
||||
speed: "Unknown",
|
||||
latency: 0,
|
||||
speed: "Unknown",
|
||||
downloadSpeed: 0,
|
||||
uploadSpeed: 0,
|
||||
status: "Unknown",
|
||||
@@ -63,40 +70,31 @@ export async function createCronJob(
|
||||
const schedule = formData.get("schedule") as string;
|
||||
const command = formData.get("command") as string;
|
||||
const comment = formData.get("comment") as string;
|
||||
const scriptContent = formData.get("scriptContent") as string;
|
||||
const selectedScriptId = formData.get("selectedScriptId") as string;
|
||||
|
||||
if (!schedule || (!command && !scriptContent)) {
|
||||
return { success: false, message: "Missing required fields" };
|
||||
if (!schedule) {
|
||||
return { success: false, message: "Schedule is required" };
|
||||
}
|
||||
|
||||
let finalCommand = command;
|
||||
|
||||
// If script content is provided, save it as a file
|
||||
if (scriptContent) {
|
||||
try {
|
||||
// Create scripts directory if it doesn't exist
|
||||
const scriptsDir = join(process.cwd(), "scripts");
|
||||
await mkdir(scriptsDir, { recursive: true });
|
||||
// If a script is selected, use the script file path
|
||||
if (selectedScriptId) {
|
||||
// Get the script filename from the selected script
|
||||
const { fetchScripts } = await import("../scripts");
|
||||
const scripts = await fetchScripts();
|
||||
const selectedScript = scripts.find((s) => s.id === selectedScriptId);
|
||||
|
||||
// Generate unique filename
|
||||
const filename = `script_${Date.now()}.sh`;
|
||||
const scriptPath = join(scriptsDir, filename);
|
||||
|
||||
// Add shebang and save the script
|
||||
const fullScript = `#!/bin/bash\n${scriptContent}`;
|
||||
await writeFile(scriptPath, fullScript, "utf8");
|
||||
|
||||
// Make the script executable
|
||||
const { exec } = await import("child_process");
|
||||
const { promisify } = await import("util");
|
||||
const execAsync = promisify(exec);
|
||||
await execAsync(`chmod +x "${scriptPath}"`);
|
||||
|
||||
finalCommand = scriptPath;
|
||||
} catch (error) {
|
||||
console.error("Error saving script:", error);
|
||||
return { success: false, message: "Failed to save script file" };
|
||||
if (selectedScript) {
|
||||
finalCommand = getScriptPath(selectedScript.filename);
|
||||
} else {
|
||||
return { success: false, message: "Selected script not found" };
|
||||
}
|
||||
} else if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Command or script selection is required",
|
||||
};
|
||||
}
|
||||
|
||||
const success = await addCronJob(schedule, finalCommand, comment);
|
||||
@@ -154,3 +152,33 @@ export async function editCronJob(
|
||||
return { success: false, message: "Error updating cron job" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneCronJob(
|
||||
id: string,
|
||||
newComment: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const cronJobs = await getCronJobs();
|
||||
const originalJob = cronJobs.find((job) => job.id === id);
|
||||
|
||||
if (!originalJob) {
|
||||
return { success: false, message: "Cron job not found" };
|
||||
}
|
||||
|
||||
const success = await addCronJob(
|
||||
originalJob.schedule,
|
||||
originalJob.command,
|
||||
newComment
|
||||
);
|
||||
|
||||
if (success) {
|
||||
revalidatePath("/");
|
||||
return { success: true, message: "Cron job cloned successfully" };
|
||||
} else {
|
||||
return { success: false, message: "Failed to clone cron job" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cloning cron job:", error);
|
||||
return { success: false, message: "Error cloning cron job" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,126 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { writeFile, readFile, unlink, mkdir } from "fs/promises";
|
||||
import { writeFile, readFile, unlink, mkdir, readdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { SCRIPTS_DIR } from "@/app/_utils/scripts";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface Script {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
const SCRIPTS_FILE = join(process.cwd(), "data", "scripts.json");
|
||||
const SCRIPTS_METADATA_FILE = join(
|
||||
process.cwd(),
|
||||
"data",
|
||||
"scripts-metadata.json"
|
||||
);
|
||||
|
||||
// Function to sanitize script names for filenames
|
||||
function sanitizeScriptName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "") // Remove special characters except spaces and hyphens
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.replace(/-+/g, "-") // Replace multiple hyphens with single hyphen
|
||||
.replace(/^-|-$/g, "") // Remove leading/trailing hyphens
|
||||
.substring(0, 50); // Limit length
|
||||
}
|
||||
|
||||
// Function to generate unique filename
|
||||
async function generateUniqueFilename(baseName: string): Promise<string> {
|
||||
const scripts = await readScriptsMetadata();
|
||||
const sanitizedName = sanitizeScriptName(baseName);
|
||||
let filename = `${sanitizedName}.sh`;
|
||||
let counter = 1;
|
||||
|
||||
// Check if filename already exists
|
||||
while (scripts.some((script) => script.filename === filename)) {
|
||||
filename = `${sanitizedName}-${counter}.sh`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
async function ensureScriptsDirectory() {
|
||||
if (!existsSync(SCRIPTS_DIR)) {
|
||||
await mkdir(SCRIPTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function ensureScriptsFile() {
|
||||
const dataDir = join(process.cwd(), "data");
|
||||
if (!existsSync(dataDir)) {
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!existsSync(SCRIPTS_FILE)) {
|
||||
await writeFile(SCRIPTS_FILE, JSON.stringify([], null, 2));
|
||||
if (!existsSync(SCRIPTS_METADATA_FILE)) {
|
||||
await writeFile(SCRIPTS_METADATA_FILE, JSON.stringify([], null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async function readScripts(): Promise<Script[]> {
|
||||
await ensureScriptsFile();
|
||||
async function ensureHostScriptsDirectory() {
|
||||
const hostScriptsDir = join(process.cwd(), "scripts");
|
||||
if (!existsSync(hostScriptsDir)) {
|
||||
await mkdir(hostScriptsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function readScriptsMetadata(): Promise<Script[]> {
|
||||
await ensureScriptsDirectory();
|
||||
try {
|
||||
const data = await readFile(SCRIPTS_FILE, "utf8");
|
||||
const data = await readFile(SCRIPTS_METADATA_FILE, "utf8");
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error("Error reading scripts:", error);
|
||||
console.error("Error reading scripts metadata:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeScripts(scripts: Script[]) {
|
||||
await ensureScriptsFile();
|
||||
await writeFile(SCRIPTS_FILE, JSON.stringify(scripts, null, 2));
|
||||
async function writeScriptsMetadata(scripts: Script[]) {
|
||||
await ensureScriptsDirectory();
|
||||
await writeFile(SCRIPTS_METADATA_FILE, JSON.stringify(scripts, null, 2));
|
||||
}
|
||||
|
||||
async function saveScriptFile(filename: string, content: string) {
|
||||
await ensureScriptsDirectory();
|
||||
|
||||
// Ensure both container and host scripts directories exist
|
||||
await ensureHostScriptsDirectory();
|
||||
|
||||
const scriptPath = join(SCRIPTS_DIR, filename);
|
||||
|
||||
// Add shebang if not present
|
||||
const scriptContent = content.startsWith("#!/")
|
||||
? content
|
||||
: `#!/bin/bash\n${content}`;
|
||||
|
||||
await writeFile(scriptPath, scriptContent, "utf8");
|
||||
|
||||
// Make the script executable
|
||||
try {
|
||||
await execAsync(`chmod +x "${scriptPath}"`);
|
||||
} catch (error) {
|
||||
console.error("Error making script executable:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScriptFile(filename: string) {
|
||||
const scriptPath = join(SCRIPTS_DIR, filename);
|
||||
if (existsSync(scriptPath)) {
|
||||
await unlink(scriptPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchScripts(): Promise<Script[]> {
|
||||
return await readScripts();
|
||||
return await readScriptsMetadata();
|
||||
}
|
||||
|
||||
export async function createScript(
|
||||
@@ -58,17 +135,26 @@ export async function createScript(
|
||||
return { success: false, message: "Name and content are required" };
|
||||
}
|
||||
|
||||
const scripts = await readScripts();
|
||||
const scripts = await readScriptsMetadata();
|
||||
const scriptId = `script_${Date.now()}_${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`;
|
||||
const filename = await generateUniqueFilename(name);
|
||||
|
||||
const newScript: Script = {
|
||||
id: `script_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
id: scriptId,
|
||||
name,
|
||||
description: description || "",
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
filename,
|
||||
};
|
||||
|
||||
// Save the actual script file
|
||||
await saveScriptFile(filename, content);
|
||||
|
||||
// Save metadata (without content)
|
||||
scripts.push(newScript);
|
||||
await writeScripts(scripts);
|
||||
await writeScriptsMetadata(scripts);
|
||||
revalidatePath("/");
|
||||
|
||||
return {
|
||||
@@ -95,21 +181,26 @@ export async function updateScript(
|
||||
return { success: false, message: "ID, name, and content are required" };
|
||||
}
|
||||
|
||||
const scripts = await readScripts();
|
||||
const scripts = await readScriptsMetadata();
|
||||
const scriptIndex = scripts.findIndex((s) => s.id === id);
|
||||
|
||||
if (scriptIndex === -1) {
|
||||
return { success: false, message: "Script not found" };
|
||||
}
|
||||
|
||||
const oldScript = scripts[scriptIndex];
|
||||
|
||||
// Update the actual script file
|
||||
await saveScriptFile(oldScript.filename, content);
|
||||
|
||||
// Update metadata (without content)
|
||||
scripts[scriptIndex] = {
|
||||
...scripts[scriptIndex],
|
||||
...oldScript,
|
||||
name,
|
||||
description: description || "",
|
||||
content,
|
||||
};
|
||||
|
||||
await writeScripts(scripts);
|
||||
await writeScriptsMetadata(scripts);
|
||||
revalidatePath("/");
|
||||
|
||||
return { success: true, message: "Script updated successfully" };
|
||||
@@ -123,14 +214,19 @@ export async function deleteScript(
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const scripts = await readScripts();
|
||||
const filteredScripts = scripts.filter((s) => s.id !== id);
|
||||
const scripts = await readScriptsMetadata();
|
||||
const scriptToDelete = scripts.find((s) => s.id === id);
|
||||
|
||||
if (filteredScripts.length === scripts.length) {
|
||||
if (!scriptToDelete) {
|
||||
return { success: false, message: "Script not found" };
|
||||
}
|
||||
|
||||
await writeScripts(filteredScripts);
|
||||
// Delete the actual script file
|
||||
await deleteScriptFile(scriptToDelete.filename);
|
||||
|
||||
// Update metadata
|
||||
const filteredScripts = scripts.filter((s) => s.id !== id);
|
||||
await writeScriptsMetadata(filteredScripts);
|
||||
revalidatePath("/");
|
||||
|
||||
return { success: true, message: "Script deleted successfully" };
|
||||
@@ -139,3 +235,65 @@ export async function deleteScript(
|
||||
return { success: false, message: "Error deleting script" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneScript(
|
||||
id: string,
|
||||
newName: string
|
||||
): Promise<{ success: boolean; message: string; script?: Script }> {
|
||||
try {
|
||||
const scripts = await readScriptsMetadata();
|
||||
const originalScript = scripts.find((s) => s.id === id);
|
||||
|
||||
if (!originalScript) {
|
||||
return { success: false, message: "Script not found" };
|
||||
}
|
||||
|
||||
// Get the original script content
|
||||
const content = await getScriptContent(originalScript.filename);
|
||||
|
||||
// Create new script with the same content but new name
|
||||
const scriptId = `script_${Date.now()}_${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`;
|
||||
const filename = await generateUniqueFilename(newName);
|
||||
|
||||
const newScript: Script = {
|
||||
id: scriptId,
|
||||
name: newName,
|
||||
description: originalScript.description,
|
||||
createdAt: new Date().toISOString(),
|
||||
filename,
|
||||
};
|
||||
|
||||
// Save the actual script file
|
||||
await saveScriptFile(filename, content);
|
||||
|
||||
// Save metadata
|
||||
scripts.push(newScript);
|
||||
await writeScriptsMetadata(scripts);
|
||||
revalidatePath("/");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Script cloned successfully",
|
||||
script: newScript,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error cloning script:", error);
|
||||
return { success: false, message: "Error cloning script" };
|
||||
}
|
||||
}
|
||||
|
||||
// Function to get script content from file (for editing)
|
||||
export async function getScriptContent(filename: string): Promise<string> {
|
||||
try {
|
||||
const scriptPath = join(SCRIPTS_DIR, filename);
|
||||
if (existsSync(scriptPath)) {
|
||||
return await readFile(scriptPath, "utf8");
|
||||
}
|
||||
return "";
|
||||
} catch (error) {
|
||||
console.error("Error reading script content:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
22
app/_utils/scripts.ts
Normal file
22
app/_utils/scripts.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { join } from "path";
|
||||
|
||||
// Use explicit path that works both in development and Docker container
|
||||
const SCRIPTS_DIR = join(process.cwd(), "scripts");
|
||||
|
||||
// Get the full path to a script file for cron job execution
|
||||
export function getScriptPath(filename: string): string {
|
||||
return join(SCRIPTS_DIR, filename);
|
||||
}
|
||||
|
||||
// Get the host path for scripts (for cron jobs that run on the host)
|
||||
export function getHostScriptPath(filename: string): string {
|
||||
// Use the host project directory to get the correct absolute path
|
||||
// This ensures cron jobs use the actual host path, not the container path
|
||||
const hostProjectDir =
|
||||
process.env.NEXT_PUBLIC_HOST_PROJECT_DIR || process.cwd();
|
||||
const hostScriptsDir = join(hostProjectDir, "scripts");
|
||||
return `bash ${join(hostScriptsDir, filename)}`;
|
||||
}
|
||||
|
||||
// Export the scripts directory path for other uses
|
||||
export { SCRIPTS_DIR };
|
||||
40
app/page.tsx
40
app/page.tsx
@@ -1,9 +1,9 @@
|
||||
import { SystemInfoCard } from "./_components/SystemInfo";
|
||||
import { CronJobList } from "./_components/CronJobList";
|
||||
import { ScriptsManager } from "./_components/ScriptsManager";
|
||||
import { TabbedInterface } from "./_components/TabbedInterface";
|
||||
import { getSystemInfo, getCronJobs } from "./_utils/system";
|
||||
import { fetchScripts } from "./_server/actions/scripts";
|
||||
import { ThemeToggle } from "./_components/ui/ThemeToggle";
|
||||
import { ToastContainer } from "./_components/ui/Toast";
|
||||
import { Clock, Activity } from "lucide-react";
|
||||
|
||||
export default async function Home() {
|
||||
@@ -17,9 +17,9 @@ export default async function Home() {
|
||||
<div className="min-h-screen relative">
|
||||
<div className="hero-gradient absolute inset-0 -z-10"></div>
|
||||
<div className="relative z-10">
|
||||
<header className="border-b border-border/50 bg-background/80 backdrop-blur-md sticky top-0 z-20 shadow-sm">
|
||||
<header className="border-b border-border/50 bg-background/80 backdrop-blur-md sticky top-0 z-20 shadow-sm lg:h-[90px]">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="p-3 bg-gradient-to-br from-purple-500 via-pink-500 to-orange-500 rounded-xl shadow-lg">
|
||||
@@ -31,24 +31,6 @@ export default async function Home() {
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold brand-gradient brand-text">
|
||||
ChronosFlow
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground hidden sm:block">
|
||||
Advanced Cron Management & Automation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<div className="hidden md:flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-emerald-500" />
|
||||
<span className="hidden lg:inline">
|
||||
Scheduled Jobs: {cronJobs.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,16 +41,16 @@ export default async function Home() {
|
||||
|
||||
<main className="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">
|
||||
<div className="space-y-8">
|
||||
{/* Scripts Library Section */}
|
||||
<ScriptsManager scripts={scripts} />
|
||||
|
||||
{/* Cron Jobs Section */}
|
||||
<CronJobList cronJobs={cronJobs} scripts={scripts} />
|
||||
</div>
|
||||
<TabbedInterface cronJobs={cronJobs} scripts={scripts} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
<div className="flex items-center gap-2 fixed bottom-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
9
data/scripts-metadata.json
Normal file
9
data/scripts-metadata.json
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": "script_1755518122712_bakgyx86a",
|
||||
"name": "test",
|
||||
"description": "testing",
|
||||
"createdAt": "2025-08-18T11:55:22.713Z",
|
||||
"filename": "test.sh"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
cronjob-manager:
|
||||
build: .
|
||||
@@ -8,6 +6,8 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||
# Enter the FULL relative path to the project directory where this docker-compose.yml file is located (use `pwd` to find it)
|
||||
- NEXT_PUBLIC_HOST_PROJECT_DIR=/absolute/path/to/project/directory
|
||||
volumes:
|
||||
# Mount the host's crontab for Linux/Unix systems
|
||||
- /var/spool/cron/crontabs:/var/spool/cron/crontabs:ro
|
||||
@@ -16,7 +16,10 @@ services:
|
||||
- /proc:/proc:ro
|
||||
- /sys:/sys:ro
|
||||
- /etc:/etc:ro
|
||||
- ./scripts:/app/scripts
|
||||
# Mount scripts directory for script execution
|
||||
- ${NEXT_PUBLIC_HOST_PROJECT_DIR}/scripts:/app/scripts
|
||||
# Mount data directory for persistence
|
||||
- ${NEXT_PUBLIC_HOST_PROJECT_DIR}/data:/app/data
|
||||
# Run with host network to access system information
|
||||
network_mode: host
|
||||
# Run as root to access system commands (needed for cron operations)
|
||||
|
||||
Reference in New Issue
Block a user