latest changes

This commit is contained in:
fccview
2025-08-18 13:00:10 +01:00
parent 40e456cad1
commit 3ac04bd59c
20 changed files with 1543 additions and 493 deletions

View File

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

View File

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

View File

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

View File

@@ -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&#10;# Your script here&#10;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&#10;# Your script here&#10;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}
/>
</>
);
}

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

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

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

View 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&#10;# Your script here&#10;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>
);
}

View File

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

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

View 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&#10;# Your script here&#10;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>
);
}

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
[
{
"id": "script_1755518122712_bakgyx86a",
"name": "test",
"description": "testing",
"createdAt": "2025-08-18T11:55:22.713Z",
"filename": "test.sh"
}
]

View File

@@ -1 +0,0 @@
[]

View File

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