mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-24 06:28:26 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc8cb9edb | ||
|
|
4dfdf8fc53 | ||
|
|
8cfc000893 | ||
|
|
1dde8f839e | ||
|
|
2b7d591a95 | ||
|
|
c0a9a74d7e | ||
|
|
376147fda0 | ||
|
|
9445cdeebf | ||
|
|
170ea674c4 | ||
|
|
80bd2e713f | ||
|
|
801bcf22a2 | ||
|
|
8fd7d0d80f | ||
|
|
95f113faa6 |
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -2,7 +2,7 @@ name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "legacy"]
|
||||
branches: ["main", "legacy", "feature/*", "bugfix/*"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,4 +10,6 @@ node_modules
|
||||
.next
|
||||
.vscode
|
||||
.DS_Store
|
||||
.cursorignore
|
||||
.cursorignore
|
||||
tsconfig.tsbuildinfo
|
||||
docker-compose.test.yml
|
||||
18
README.md
18
README.md
@@ -49,19 +49,21 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
|
||||
```bash
|
||||
services:
|
||||
cronjob-manager:
|
||||
image: ghcr.io/fccview/cronmaster:1.2.1
|
||||
image: ghcr.io/fccview/cronmaster:1.3.0
|
||||
container_name: cronmaster
|
||||
user: "root"
|
||||
ports:
|
||||
# Feel free to change port, 3000 is very common so I like to map it to something else
|
||||
- "40124:3000"
|
||||
- "40123:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DOCKER=true
|
||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||
# Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime.
|
||||
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||
# If docker struggles to find your crontab user, update this variable with it.
|
||||
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
|
||||
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=fccview,root,user1,user2
|
||||
# - HOST_CRONTAB_USER=fccview
|
||||
volumes:
|
||||
# Mount Docker socket to execute commands on host
|
||||
@@ -69,7 +71,7 @@ services:
|
||||
|
||||
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
||||
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
|
||||
# will target this foler (thanks to the HOST_PROJECT_DIR variable set above)
|
||||
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
|
||||
- ./scripts:/app/scripts
|
||||
- ./data:/app/data
|
||||
- ./snippets:/app/snippets
|
||||
@@ -235,6 +237,14 @@ I would like to thank the following members for raising issues and help test/deb
|
||||
<a href="https://github.com/mariushosting"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/37554361?u=9007d0600680ac2b267bde2d8c19b05c06285a34&v=4&s=100"><br />mariushosting</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="20%">
|
||||
<a href="https://github.com/DVDAndroid"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/6277172?u=78aa9b049a0c1a7ae5408d22219a8a91cfe45095&v=4&size=100"><br />DVDAndroid</a>
|
||||
</td>
|
||||
<td align="center" valign="top" width="20%">
|
||||
<a href="https://github.com/ActxLeToucan"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/56509120?u=b0a684dfa1fcf8f3f41c2ead37f6441716d8bd62&v=4&size=100"><br />ActxLeToucan</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -16,13 +16,13 @@ interface BashEditorProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function BashEditor({
|
||||
export const BashEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "#!/bin/bash\n# Your bash script here\necho 'Hello World'",
|
||||
className = "",
|
||||
label = "Bash Script",
|
||||
}: BashEditorProps) {
|
||||
}: BashEditorProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const editorViewRef = useRef<EditorView | null>(null);
|
||||
|
||||
@@ -34,7 +34,7 @@ const categoryIcons = {
|
||||
"Custom Scripts": Code,
|
||||
};
|
||||
|
||||
export function BashSnippetHelper({ onInsertSnippet }: BashSnippetHelperProps) {
|
||||
export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
@@ -27,13 +27,13 @@ interface CronExpressionHelperProps {
|
||||
showPatterns?: boolean;
|
||||
}
|
||||
|
||||
export function CronExpressionHelper({
|
||||
export const CronExpressionHelper = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "* * * * *",
|
||||
className = "",
|
||||
showPatterns = true,
|
||||
}: CronExpressionHelperProps) {
|
||||
}: CronExpressionHelperProps) => {
|
||||
const [explanation, setExplanation] = useState<CronExplanation | null>(null);
|
||||
const [showPatternsPanel, setShowPatternsPanel] = useState(false);
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
@@ -2,19 +2,33 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Trash2, Clock, Edit, Plus, Files } from "lucide-react";
|
||||
import {
|
||||
Trash2,
|
||||
Clock,
|
||||
Edit,
|
||||
Plus,
|
||||
Files,
|
||||
User,
|
||||
Play,
|
||||
Pause,
|
||||
Code,
|
||||
} from "lucide-react";
|
||||
import { CronJob } from "@/app/_utils/system";
|
||||
import {
|
||||
removeCronJob,
|
||||
editCronJob,
|
||||
createCronJob,
|
||||
cloneCronJob,
|
||||
pauseCronJobAction,
|
||||
resumeCronJobAction,
|
||||
runCronJob,
|
||||
} from "@/app/_server/actions/cronjobs";
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { CreateTaskModal } from "./modals/CreateTaskModal";
|
||||
import { EditTaskModal } from "./modals/EditTaskModal";
|
||||
import { DeleteTaskModal } from "./modals/DeleteTaskModal";
|
||||
import { CloneTaskModal } from "./modals/CloneTaskModal";
|
||||
import { UserFilter } from "./ui/UserFilter";
|
||||
import { type Script } from "@/app/_server/actions/scripts";
|
||||
import { showToast } from "./ui/Toast";
|
||||
|
||||
@@ -23,7 +37,7 @@ interface CronJobListProps {
|
||||
scripts: Script[];
|
||||
}
|
||||
|
||||
export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [editingJob, setEditingJob] = useState<CronJob | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
@@ -33,6 +47,24 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
const [jobToDelete, setJobToDelete] = useState<CronJob | null>(null);
|
||||
const [jobToClone, setJobToClone] = useState<CronJob | null>(null);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [runningJobId, setRunningJobId] = useState<string | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("selectedCronUser");
|
||||
if (savedUser) {
|
||||
setSelectedUser(savedUser);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUser) {
|
||||
localStorage.setItem("selectedCronUser", selectedUser);
|
||||
} else {
|
||||
localStorage.removeItem("selectedCronUser");
|
||||
}
|
||||
}, [selectedUser]);
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
schedule: "",
|
||||
command: "",
|
||||
@@ -43,8 +75,14 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
command: "",
|
||||
comment: "",
|
||||
selectedScriptId: null as string | null,
|
||||
user: "",
|
||||
});
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
if (!selectedUser) return cronJobs;
|
||||
return cronJobs.filter((job) => job.user === selectedUser);
|
||||
}, [cronJobs, selectedUser]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
@@ -85,6 +123,62 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async (id: string) => {
|
||||
try {
|
||||
const result = await pauseCronJobAction(id);
|
||||
if (result.success) {
|
||||
showToast("success", "Cron job paused successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to pause cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast("error", "Failed to pause cron job", "Please try again later.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async (id: string) => {
|
||||
try {
|
||||
const result = await resumeCronJobAction(id);
|
||||
if (result.success) {
|
||||
showToast("success", "Cron job resumed successfully");
|
||||
} else {
|
||||
showToast("error", "Failed to resume cron job", result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to resume cron job",
|
||||
"Please try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = async (id: string) => {
|
||||
setRunningJobId(id);
|
||||
try {
|
||||
const result = await runCronJob(id);
|
||||
if (result.success) {
|
||||
showToast("success", "Cron job executed successfully");
|
||||
if (result.output) {
|
||||
console.log("Command output:", result.output);
|
||||
}
|
||||
} else {
|
||||
showToast("error", "Failed to execute cron job", result.message);
|
||||
if (result.output) {
|
||||
console.error("Command error:", result.output);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
"error",
|
||||
"Failed to execute cron job",
|
||||
"Please try again later."
|
||||
);
|
||||
} finally {
|
||||
setRunningJobId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (job: CronJob) => {
|
||||
setJobToDelete(job);
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -135,11 +229,13 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
|
||||
const handleNewCronSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("schedule", newCronForm.schedule);
|
||||
formData.append("command", newCronForm.command);
|
||||
formData.append("comment", newCronForm.comment);
|
||||
formData.append("user", newCronForm.user);
|
||||
if (newCronForm.selectedScriptId) {
|
||||
formData.append("selectedScriptId", newCronForm.selectedScriptId);
|
||||
}
|
||||
@@ -152,6 +248,7 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
command: "",
|
||||
comment: "",
|
||||
selectedScriptId: null,
|
||||
user: "",
|
||||
});
|
||||
showToast("success", "Cron job created successfully");
|
||||
} else {
|
||||
@@ -180,8 +277,9 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
Scheduled Tasks
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{cronJobs.length} scheduled job
|
||||
{cronJobs.length !== 1 ? "s" : ""}
|
||||
{filteredJobs.length} of {cronJobs.length} scheduled job
|
||||
{filteredJobs.length !== 1 ? "s" : ""}
|
||||
{selectedUser && ` for ${selectedUser}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,17 +293,28 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cronJobs.length === 0 ? (
|
||||
<div className="mb-4">
|
||||
<UserFilter
|
||||
selectedUser={selectedUser}
|
||||
onUserChange={setSelectedUser}
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredJobs.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="mx-auto w-20 h-20 bg-gradient-to-br from-primary/20 to-blue-500/20 rounded-full flex items-center justify-center mb-6">
|
||||
<Clock className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3 brand-gradient">
|
||||
No scheduled tasks yet
|
||||
{selectedUser
|
||||
? `No tasks for user ${selectedUser}`
|
||||
: "No scheduled tasks yet"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
Create your first scheduled task to automate your system
|
||||
operations and boost productivity.
|
||||
{selectedUser
|
||||
? `No scheduled tasks found for user ${selectedUser}. Try selecting a different user or create a new task.`
|
||||
: "Create your first scheduled task to automate your system operations and boost productivity."}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setIsNewCronModalOpen(true)}
|
||||
@@ -218,13 +327,13 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cronJobs.map((job) => (
|
||||
{filteredJobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="glass-card p-4 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div className="flex-1 min-w-0 order-2 lg:order-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<code className="text-sm bg-purple-500/10 text-purple-600 dark:text-purple-400 px-2 py-1 rounded font-mono border border-purple-500/20">
|
||||
{job.schedule}
|
||||
@@ -239,6 +348,18 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{job.user}</span>
|
||||
</div>
|
||||
{job.paused && (
|
||||
<span className="text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 px-2 py-0.5 rounded border border-yellow-500/20">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.comment && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground italic truncate"
|
||||
@@ -249,7 +370,22 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 order-1 lg:order-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRun(job.id)}
|
||||
disabled={runningJobId === job.id || job.paused}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Run cron job manually"
|
||||
aria-label="Run cron job manually"
|
||||
>
|
||||
{runningJobId === job.id ? (
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<Code className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -270,6 +406,29 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
||||
>
|
||||
<Files className="h-3 w-3" />
|
||||
</Button>
|
||||
{job.paused ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResume(job.id)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Resume cron job"
|
||||
aria-label="Resume cron job"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePause(job.id)}
|
||||
className="btn-outline h-8 px-3"
|
||||
title="Pause cron job"
|
||||
aria-label="Pause cron job"
|
||||
>
|
||||
<Pause className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
|
||||
@@ -31,9 +31,9 @@ interface ScriptsManagerProps {
|
||||
scripts: Script[];
|
||||
}
|
||||
|
||||
export function ScriptsManager({
|
||||
export const ScriptsManager = ({
|
||||
scripts: initialScripts,
|
||||
}: ScriptsManagerProps) {
|
||||
}: ScriptsManagerProps) => {
|
||||
const [scripts, setScripts] = useState<Script[]>(initialScripts);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
|
||||
@@ -60,9 +60,9 @@ interface SystemInfoCardProps {
|
||||
systemInfo: SystemInfoType;
|
||||
}
|
||||
|
||||
export function SystemInfoCard({
|
||||
export const SystemInfoCard = ({
|
||||
systemInfo: initialSystemInfo,
|
||||
}: SystemInfoCardProps) {
|
||||
}: SystemInfoCardProps) => {
|
||||
const [currentTime, setCurrentTime] = useState<string>("");
|
||||
const [systemInfo, setSystemInfo] =
|
||||
useState<SystemInfoType>(initialSystemInfo);
|
||||
|
||||
@@ -12,7 +12,7 @@ interface TabbedInterfaceProps {
|
||||
scripts: Script[];
|
||||
}
|
||||
|
||||
export function TabbedInterface({ cronJobs, scripts }: TabbedInterfaceProps) {
|
||||
export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) => {
|
||||
const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">(
|
||||
"cronjobs"
|
||||
);
|
||||
@@ -23,11 +23,10 @@ export function TabbedInterface({ cronJobs, scripts }: TabbedInterfaceProps) {
|
||||
<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"
|
||||
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
|
||||
@@ -37,11 +36,10 @@ export function TabbedInterface({ cronJobs, scripts }: TabbedInterfaceProps) {
|
||||
</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"
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, FileText } from "lucide-react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { Input } from "../ui/Input";
|
||||
@@ -15,18 +15,18 @@ interface CloneScriptModalProps {
|
||||
isCloning: boolean;
|
||||
}
|
||||
|
||||
export function CloneScriptModal({
|
||||
export const CloneScriptModal = ({
|
||||
script,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isCloning,
|
||||
}: CloneScriptModalProps) {
|
||||
}: CloneScriptModalProps) => {
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (newName.trim()) {
|
||||
onConfirm(newName.trim());
|
||||
|
||||
@@ -15,18 +15,18 @@ interface CloneTaskModalProps {
|
||||
isCloning: boolean;
|
||||
}
|
||||
|
||||
export function CloneTaskModal({
|
||||
export const CloneTaskModal = ({
|
||||
cronJob,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isCloning,
|
||||
}: CloneTaskModalProps) {
|
||||
}: CloneTaskModalProps) => {
|
||||
const [newComment, setNewComment] = useState("");
|
||||
|
||||
if (!isOpen || !cronJob) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (newComment.trim()) {
|
||||
onConfirm(newComment.trim());
|
||||
|
||||
@@ -17,13 +17,13 @@ interface CreateScriptModalProps {
|
||||
onFormChange: (updates: Partial<CreateScriptModalProps["form"]>) => void;
|
||||
}
|
||||
|
||||
export function CreateScriptModal({
|
||||
export const CreateScriptModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
form,
|
||||
onFormChange,
|
||||
}: CreateScriptModalProps) {
|
||||
}: CreateScriptModalProps) => {
|
||||
return (
|
||||
<ScriptModal
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { CronExpressionHelper } from "../CronExpressionHelper";
|
||||
import { SelectScriptModal } from "./SelectScriptModal";
|
||||
import { UserSwitcher } from "../ui/UserSwitcher";
|
||||
import { Plus, Terminal, FileText, X } from "lucide-react";
|
||||
import { getScriptContent } from "@/app/_server/actions/scripts";
|
||||
import { getHostScriptPath } from "@/app/_utils/scripts";
|
||||
@@ -21,25 +22,26 @@ interface Script {
|
||||
interface CreateTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
scripts: Script[];
|
||||
form: {
|
||||
schedule: string;
|
||||
command: string;
|
||||
comment: string;
|
||||
selectedScriptId: string | null;
|
||||
user: string;
|
||||
};
|
||||
onFormChange: (updates: Partial<CreateTaskModalProps["form"]>) => void;
|
||||
}
|
||||
|
||||
export function CreateTaskModal({
|
||||
export const CreateTaskModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
scripts,
|
||||
form,
|
||||
onFormChange,
|
||||
}: CreateTaskModalProps) {
|
||||
}: CreateTaskModalProps) => {
|
||||
const [selectedScriptContent, setSelectedScriptContent] =
|
||||
useState<string>("");
|
||||
const [isSelectScriptModalOpen, setIsSelectScriptModalOpen] = useState(false);
|
||||
@@ -88,6 +90,16 @@ export function CreateTaskModal({
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
User
|
||||
</label>
|
||||
<UserSwitcher
|
||||
selectedUser={form.user}
|
||||
onUserChange={(user) => onFormChange({ user })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Schedule
|
||||
@@ -108,11 +120,10 @@ export function CreateTaskModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomCommand}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
!form.selectedScriptId
|
||||
className={`p-4 rounded-lg border-2 transition-all ${!form.selectedScriptId
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="h-5 w-5" />
|
||||
@@ -126,11 +137,10 @@ export function CreateTaskModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSelectScriptModalOpen(true)}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
form.selectedScriptId
|
||||
className={`p-4 rounded-lg border-2 transition-all ${form.selectedScriptId
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5" />
|
||||
|
||||
@@ -13,13 +13,13 @@ interface DeleteScriptModalProps {
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export function DeleteScriptModal({
|
||||
export const DeleteScriptModal = ({
|
||||
script,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isDeleting,
|
||||
}: DeleteScriptModalProps) {
|
||||
}: DeleteScriptModalProps) => {
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,12 +18,12 @@ interface DeleteTaskModalProps {
|
||||
job: CronJob | null;
|
||||
}
|
||||
|
||||
export function DeleteTaskModal({
|
||||
export const DeleteTaskModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
job,
|
||||
}: DeleteTaskModalProps) {
|
||||
}: DeleteTaskModalProps) => {
|
||||
if (!job) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,14 +19,14 @@ interface EditScriptModalProps {
|
||||
onFormChange: (updates: Partial<EditScriptModalProps["form"]>) => void;
|
||||
}
|
||||
|
||||
export function EditScriptModal({
|
||||
export const EditScriptModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
script,
|
||||
form,
|
||||
onFormChange,
|
||||
}: EditScriptModalProps) {
|
||||
}: EditScriptModalProps) => {
|
||||
if (!script) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Edit, Terminal } from "lucide-react";
|
||||
interface EditTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
form: {
|
||||
schedule: string;
|
||||
command: string;
|
||||
@@ -18,13 +18,13 @@ interface EditTaskModalProps {
|
||||
onFormChange: (updates: Partial<EditTaskModalProps["form"]>) => void;
|
||||
}
|
||||
|
||||
export function EditTaskModal({
|
||||
export const EditTaskModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
form,
|
||||
onFormChange,
|
||||
}: EditTaskModalProps) {
|
||||
}: EditTaskModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { BashEditor } from "../BashEditor";
|
||||
import { BashSnippetHelper } from "../BashSnippetHelper";
|
||||
import { FileText, Code, Plus, Edit } from "lucide-react";
|
||||
import { FileText, Code } from "lucide-react";
|
||||
import { showToast } from "../ui/Toast";
|
||||
|
||||
interface ScriptModalProps {
|
||||
@@ -26,7 +26,7 @@ interface ScriptModalProps {
|
||||
additionalFormData?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function ScriptModal({
|
||||
export const ScriptModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
@@ -36,8 +36,8 @@ export function ScriptModal({
|
||||
form,
|
||||
onFormChange,
|
||||
additionalFormData = {},
|
||||
}: ScriptModalProps) {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
}: ScriptModalProps) => {
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.name.trim()) {
|
||||
|
||||
@@ -17,13 +17,13 @@ interface SelectScriptModalProps {
|
||||
selectedScriptId: string | null;
|
||||
}
|
||||
|
||||
export function SelectScriptModal({
|
||||
export const SelectScriptModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
scripts,
|
||||
onScriptSelect,
|
||||
selectedScriptId,
|
||||
}: SelectScriptModalProps) {
|
||||
}: SelectScriptModalProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [previewScript, setPreviewScript] = useState<Script | null>(null);
|
||||
const [previewContent, setPreviewContent] = useState<string>("");
|
||||
@@ -109,11 +109,10 @@ export function SelectScriptModal({
|
||||
<button
|
||||
key={script.id}
|
||||
onClick={() => handleScriptClick(script)}
|
||||
className={`w-full p-4 text-left hover:bg-accent/30 transition-colors ${
|
||||
previewScript?.id === script.id
|
||||
className={`w-full p-4 text-left hover:bg-accent/30 transition-colors ${previewScript?.id === script.id
|
||||
? "bg-primary/5 border-r-2 border-primary"
|
||||
: ""
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
@@ -36,9 +36,3 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from '@/app/_utils/cn';
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -69,4 +69,4 @@ const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
export { CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InputHTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { }
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
@@ -20,9 +20,3 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface MetricCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
progressMax?: number;
|
||||
}
|
||||
|
||||
const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
||||
export const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -99,5 +99,3 @@ const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
||||
);
|
||||
|
||||
MetricCard.displayName = "MetricCard";
|
||||
|
||||
export { MetricCard };
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ModalProps {
|
||||
preventCloseOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
export const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
@@ -23,7 +23,7 @@ export function Modal({
|
||||
size = "md",
|
||||
showCloseButton = true,
|
||||
preventCloseOnClickOutside = false,
|
||||
}: ModalProps) {
|
||||
}: ModalProps) => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface PerformanceSummaryProps
|
||||
metrics: PerformanceMetric[];
|
||||
}
|
||||
|
||||
const PerformanceSummary = forwardRef<HTMLDivElement, PerformanceSummaryProps>(
|
||||
export const PerformanceSummary = forwardRef<HTMLDivElement, PerformanceSummaryProps>(
|
||||
({ className, metrics, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
@@ -58,5 +58,3 @@ const PerformanceSummary = forwardRef<HTMLDivElement, PerformanceSummaryProps>(
|
||||
);
|
||||
|
||||
PerformanceSummary.displayName = "PerformanceSummary";
|
||||
|
||||
export { PerformanceSummary };
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ProgressBarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "gradient";
|
||||
}
|
||||
|
||||
const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
|
||||
export const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -75,5 +75,3 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
|
||||
);
|
||||
|
||||
ProgressBar.displayName = "ProgressBar";
|
||||
|
||||
export { ProgressBar };
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface SidebarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
};
|
||||
}
|
||||
|
||||
const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
|
||||
export const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -185,5 +185,3 @@ const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
|
||||
);
|
||||
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
export { Sidebar };
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface StatusBadgeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
|
||||
export const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
@@ -105,5 +105,3 @@ const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
|
||||
);
|
||||
|
||||
StatusBadge.displayName = "StatusBadge";
|
||||
|
||||
export { StatusBadge };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cn } from "@/app/_utils/cn";
|
||||
import { HTMLAttributes, forwardRef } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
export interface SystemStatusProps extends HTMLAttributes<HTMLDivElement> {
|
||||
status: string;
|
||||
@@ -10,7 +9,7 @@ export interface SystemStatusProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isUpdating?: boolean;
|
||||
}
|
||||
|
||||
const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
|
||||
export const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
|
||||
(
|
||||
{ className, status, details, timestamp, isUpdating = false, ...props },
|
||||
ref
|
||||
@@ -80,5 +79,3 @@ const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
|
||||
);
|
||||
|
||||
SystemStatus.displayName = "SystemStatus";
|
||||
|
||||
export { SystemStatus };
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import { type ThemeProviderProps } from 'next-themes/dist/types';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from './Button';
|
||||
|
||||
export function ThemeToggle() {
|
||||
export const ThemeToggle = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
@@ -28,8 +28,4 @@ export function ThemeToggle() {
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ const toastStyles = {
|
||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
|
||||
};
|
||||
|
||||
export function Toast({ toast, onRemove }: ToastProps) {
|
||||
export const Toast = ({ toast, onRemove }: ToastProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const Icon = toastIcons[toast.type];
|
||||
|
||||
@@ -75,7 +75,7 @@ export function Toast({ toast, onRemove }: ToastProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
export const ToastContainer = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = (toast: Omit<Toast, "id">) => {
|
||||
@@ -103,12 +103,12 @@ export function ToastContainer() {
|
||||
);
|
||||
}
|
||||
|
||||
export function showToast(
|
||||
export const 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 });
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface TruncatedTextProps extends HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TruncatedText = forwardRef<HTMLDivElement, TruncatedTextProps>(
|
||||
export const TruncatedText = forwardRef<HTMLDivElement, TruncatedTextProps>(
|
||||
({ className, text, maxLength = 50, showTooltip = true, ...props }, ref) => {
|
||||
const [showTooltipState, setShowTooltipState] = useState(false);
|
||||
const shouldTruncate = text.length > maxLength;
|
||||
@@ -42,5 +42,3 @@ const TruncatedText = forwardRef<HTMLDivElement, TruncatedTextProps>(
|
||||
);
|
||||
|
||||
TruncatedText.displayName = "TruncatedText";
|
||||
|
||||
export { TruncatedText };
|
||||
|
||||
107
app/_components/ui/UserFilter.tsx
Normal file
107
app/_components/ui/UserFilter.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { ChevronDown, User, X } from "lucide-react";
|
||||
import { fetchAvailableUsers } from "@/app/_server/actions/cronjobs";
|
||||
|
||||
interface UserFilterProps {
|
||||
selectedUser: string | null;
|
||||
onUserChange: (user: string | null) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UserFilter = ({
|
||||
selectedUser,
|
||||
onUserChange,
|
||||
className = "",
|
||||
}: UserFilterProps) => {
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const availableUsers = await fetchAvailableUsers();
|
||||
setUsers(availableUsers);
|
||||
} catch (error) {
|
||||
console.error("Error loading users:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md ${className}`}
|
||||
>
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Loading users...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
{selectedUser ? `User: ${selectedUser}` : "All users"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedUser && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUserChange(null);
|
||||
}}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 max-h-48 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => {
|
||||
onUserChange(null);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${!selectedUser ? "bg-accent text-accent-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
All users
|
||||
</button>
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user}
|
||||
onClick={() => {
|
||||
onUserChange(user);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${selectedUser === user ? "bg-accent text-accent-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{user}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
app/_components/ui/UserSwitcher.tsx
Normal file
85
app/_components/ui/UserSwitcher.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { ChevronDown, User } from "lucide-react";
|
||||
import { fetchAvailableUsers } from "@/app/_server/actions/cronjobs";
|
||||
|
||||
interface UserSwitcherProps {
|
||||
selectedUser: string;
|
||||
onUserChange: (user: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UserSwitcher = ({
|
||||
selectedUser,
|
||||
onUserChange,
|
||||
className = "",
|
||||
}: UserSwitcherProps) => {
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const availableUsers = await fetchAvailableUsers();
|
||||
setUsers(availableUsers);
|
||||
if (availableUsers.length > 0 && !selectedUser) {
|
||||
onUserChange(availableUsers[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading users:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUsers();
|
||||
}, [selectedUser, onUserChange]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md ${className}`}
|
||||
>
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Loading users...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="text-sm">{selectedUser || "Select user"}</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 max-h-48 overflow-y-auto">
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user}
|
||||
onClick={() => {
|
||||
onUserChange(user);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${selectedUser === user ? "bg-accent text-accent-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{user}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,20 @@ import {
|
||||
addCronJob,
|
||||
deleteCronJob,
|
||||
updateCronJob,
|
||||
pauseCronJob,
|
||||
resumeCronJob,
|
||||
cleanupCrontab,
|
||||
type CronJob,
|
||||
} from "@/app/_utils/system";
|
||||
import { getAllTargetUsers, getUserInfo } from "@/app/_utils/system/hostCrontab";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getScriptPath } from "@/app/_utils/scripts";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
export async function fetchCronJobs(): Promise<CronJob[]> {
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const fetchCronJobs = async (): Promise<CronJob[]> => {
|
||||
try {
|
||||
return await getCronJobs();
|
||||
} catch (error) {
|
||||
@@ -19,14 +27,15 @@ export async function fetchCronJobs(): Promise<CronJob[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCronJob(
|
||||
export const createCronJob = async (
|
||||
formData: FormData
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const schedule = formData.get("schedule") as string;
|
||||
const command = formData.get("command") as string;
|
||||
const comment = formData.get("comment") as string;
|
||||
const selectedScriptId = formData.get("selectedScriptId") as string;
|
||||
const user = formData.get("user") as string;
|
||||
|
||||
if (!schedule) {
|
||||
return { success: false, message: "Schedule is required" };
|
||||
@@ -51,7 +60,7 @@ export async function createCronJob(
|
||||
};
|
||||
}
|
||||
|
||||
const success = await addCronJob(schedule, finalCommand, comment);
|
||||
const success = await addCronJob(schedule, finalCommand, comment, user);
|
||||
if (success) {
|
||||
revalidatePath("/");
|
||||
return { success: true, message: "Cron job created successfully" };
|
||||
@@ -64,9 +73,9 @@ export async function createCronJob(
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCronJob(
|
||||
export const removeCronJob = async (
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const success = await deleteCronJob(id);
|
||||
if (success) {
|
||||
@@ -81,9 +90,9 @@ export async function removeCronJob(
|
||||
}
|
||||
}
|
||||
|
||||
export async function editCronJob(
|
||||
export const editCronJob = async (
|
||||
formData: FormData
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const id = formData.get("id") as string;
|
||||
const schedule = formData.get("schedule") as string;
|
||||
@@ -107,10 +116,10 @@ export async function editCronJob(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneCronJob(
|
||||
export const cloneCronJob = async (
|
||||
id: string,
|
||||
newComment: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const cronJobs = await getCronJobs();
|
||||
const originalJob = cronJobs.find((job) => job.id === id);
|
||||
@@ -122,7 +131,8 @@ export async function cloneCronJob(
|
||||
const success = await addCronJob(
|
||||
originalJob.schedule,
|
||||
originalJob.command,
|
||||
newComment
|
||||
newComment,
|
||||
originalJob.user
|
||||
);
|
||||
|
||||
if (success) {
|
||||
@@ -136,3 +146,112 @@ export async function cloneCronJob(
|
||||
return { success: false, message: "Error cloning cron job" };
|
||||
}
|
||||
}
|
||||
|
||||
export const pauseCronJobAction = async (
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const success = await pauseCronJob(id);
|
||||
if (success) {
|
||||
revalidatePath("/");
|
||||
return { success: true, message: "Cron job paused successfully" };
|
||||
} else {
|
||||
return { success: false, message: "Failed to pause cron job" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error pausing cron job:", error);
|
||||
return { success: false, message: "Error pausing cron job" };
|
||||
}
|
||||
}
|
||||
|
||||
export const resumeCronJobAction = async (
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const success = await resumeCronJob(id);
|
||||
if (success) {
|
||||
revalidatePath("/");
|
||||
return { success: true, message: "Cron job resumed successfully" };
|
||||
} else {
|
||||
return { success: false, message: "Failed to resume cron job" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error resuming cron job:", error);
|
||||
return { success: false, message: "Error resuming cron job" };
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchAvailableUsers = async (): Promise<string[]> => {
|
||||
try {
|
||||
return await getAllTargetUsers();
|
||||
} catch (error) {
|
||||
console.error("Error fetching available users:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanupCrontabAction = async (): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const success = await cleanupCrontab();
|
||||
if (success) {
|
||||
revalidatePath("/");
|
||||
return { success: true, message: "Crontab cleaned successfully" };
|
||||
} else {
|
||||
return { success: false, message: "Failed to clean crontab" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cleaning crontab:", error);
|
||||
return { success: false, message: "Error cleaning crontab" };
|
||||
}
|
||||
}
|
||||
|
||||
export const runCronJob = async (
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string; output?: string }> => {
|
||||
try {
|
||||
const cronJobs = await getCronJobs();
|
||||
const job = cronJobs.find((j) => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
return { success: false, message: "Cron job not found" };
|
||||
}
|
||||
|
||||
if (job.paused) {
|
||||
return { success: false, message: "Cannot run paused cron job" };
|
||||
}
|
||||
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
let command = job.command;
|
||||
|
||||
if (isDocker) {
|
||||
const userInfo = await getUserInfo(job.user);
|
||||
|
||||
if (userInfo && userInfo.username !== "root") {
|
||||
command = `nsenter -t 1 -m -u -i -n -p --setuid=${userInfo.uid} --setgid=${userInfo.gid} sh -c "${job.command}"`;
|
||||
} else {
|
||||
command = `nsenter -t 1 -m -u -i -n -p sh -c "${job.command}"`;
|
||||
}
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 30000,
|
||||
cwd: process.env.HOME || "/home",
|
||||
});
|
||||
|
||||
const output = stdout || stderr || "Command executed successfully";
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Cron job executed successfully",
|
||||
output: output.trim()
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Error running cron job:", error);
|
||||
const errorMessage = error.stderr || error.message || "Unknown error occurred";
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to execute cron job",
|
||||
output: errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const execAsync = promisify(exec);
|
||||
|
||||
export type { Script } from "@/app/_utils/scriptScanner";
|
||||
|
||||
function sanitizeScriptName(name: string): string {
|
||||
const sanitizeScriptName = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
@@ -23,7 +23,7 @@ function sanitizeScriptName(name: string): string {
|
||||
.substring(0, 50);
|
||||
}
|
||||
|
||||
async function generateUniqueFilename(baseName: string): Promise<string> {
|
||||
const generateUniqueFilename = async (baseName: string): Promise<string> => {
|
||||
const scripts = await loadAllScripts();
|
||||
let filename = `${sanitizeScriptName(baseName)}.sh`;
|
||||
let counter = 1;
|
||||
@@ -36,14 +36,14 @@ async function generateUniqueFilename(baseName: string): Promise<string> {
|
||||
return filename;
|
||||
}
|
||||
|
||||
async function ensureScriptsDirectory() {
|
||||
const ensureScriptsDirectory = async () => {
|
||||
const scriptsDir = await SCRIPTS_DIR();
|
||||
if (!existsSync(scriptsDir)) {
|
||||
await mkdir(scriptsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureHostScriptsDirectory() {
|
||||
const ensureHostScriptsDirectory = async () => {
|
||||
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
|
||||
|
||||
const hostScriptsDir = join(hostProjectDir, "scripts");
|
||||
@@ -52,7 +52,7 @@ async function ensureHostScriptsDirectory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScriptFile(filename: string, content: string) {
|
||||
const saveScriptFile = async (filename: string, content: string) => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptsDir = isDocker ? "/app/scripts" : await SCRIPTS_DIR();
|
||||
await ensureScriptsDirectory();
|
||||
@@ -61,20 +61,20 @@ async function saveScriptFile(filename: string, content: string) {
|
||||
await writeFile(scriptPath, content, "utf8");
|
||||
}
|
||||
|
||||
async function deleteScriptFile(filename: string) {
|
||||
const deleteScriptFile = async (filename: string) => {
|
||||
const scriptPath = join(await SCRIPTS_DIR(), filename);
|
||||
if (existsSync(scriptPath)) {
|
||||
await unlink(scriptPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchScripts(): Promise<Script[]> {
|
||||
export const fetchScripts = async (): Promise<Script[]> => {
|
||||
return await loadAllScripts();
|
||||
}
|
||||
|
||||
export async function createScript(
|
||||
export const createScript = async (
|
||||
formData: FormData
|
||||
): Promise<{ success: boolean; message: string; script?: Script }> {
|
||||
): Promise<{ success: boolean; message: string; script?: Script }> => {
|
||||
try {
|
||||
const name = formData.get("name") as string;
|
||||
const description = formData.get("description") as string;
|
||||
@@ -119,9 +119,9 @@ export async function createScript(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateScript(
|
||||
export const updateScript = async (
|
||||
formData: FormData
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const id = formData.get("id") as string;
|
||||
const name = formData.get("name") as string;
|
||||
@@ -157,9 +157,9 @@ export async function updateScript(
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteScript(
|
||||
export const deleteScript = async (
|
||||
id: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const scripts = await loadAllScripts();
|
||||
const script = scripts.find((s) => s.id === id);
|
||||
@@ -178,10 +178,10 @@ export async function deleteScript(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneScript(
|
||||
export const cloneScript = async (
|
||||
id: string,
|
||||
newName: string
|
||||
): Promise<{ success: boolean; message: string; script?: Script }> {
|
||||
): Promise<{ success: boolean; message: string; script?: Script }> => {
|
||||
try {
|
||||
const scripts = await loadAllScripts();
|
||||
const originalScript = scripts.find((s) => s.id === id);
|
||||
@@ -227,7 +227,7 @@ export async function cloneScript(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getScriptContent(filename: string): Promise<string> {
|
||||
export const getScriptContent = async (filename: string): Promise<string> => {
|
||||
try {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptPath = isDocker
|
||||
@@ -260,11 +260,11 @@ export async function getScriptContent(filename: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeScript(filename: string): Promise<{
|
||||
export const executeScript = async (filename: string): Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
error: string;
|
||||
}> {
|
||||
}> => {
|
||||
try {
|
||||
await ensureHostScriptsDirectory();
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
loadAllSnippets,
|
||||
searchBashSnippets,
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
|
||||
export { type BashSnippet } from "@/app/_utils/snippetScanner";
|
||||
|
||||
export async function fetchSnippets(): Promise<BashSnippet[]> {
|
||||
export const fetchSnippets = async (): Promise<BashSnippet[]> => {
|
||||
try {
|
||||
return await loadAllSnippets();
|
||||
} catch (error) {
|
||||
@@ -20,7 +19,7 @@ export async function fetchSnippets(): Promise<BashSnippet[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchSnippets(query: string): Promise<BashSnippet[]> {
|
||||
export const searchSnippets = async (query: string): Promise<BashSnippet[]> => {
|
||||
try {
|
||||
const snippets = await loadAllSnippets();
|
||||
return searchBashSnippets(snippets, query);
|
||||
@@ -30,7 +29,7 @@ export async function searchSnippets(query: string): Promise<BashSnippet[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSnippetCategories(): Promise<string[]> {
|
||||
export const fetchSnippetCategories = async (): Promise<string[]> => {
|
||||
try {
|
||||
const snippets = await loadAllSnippets();
|
||||
return getSnippetCategories(snippets);
|
||||
@@ -40,9 +39,9 @@ export async function fetchSnippetCategories(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSnippetById(
|
||||
export const fetchSnippetById = async (
|
||||
id: string
|
||||
): Promise<BashSnippet | undefined> {
|
||||
): Promise<BashSnippet | undefined> => {
|
||||
try {
|
||||
const snippets = await loadAllSnippets();
|
||||
return getSnippetById(snippets, id);
|
||||
@@ -52,9 +51,9 @@ export async function fetchSnippetById(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSnippetsByCategory(
|
||||
export const fetchSnippetsByCategory = async (
|
||||
category: string
|
||||
): Promise<BashSnippet[]> {
|
||||
): Promise<BashSnippet[]> => {
|
||||
try {
|
||||
const snippets = await loadAllSnippets();
|
||||
return snippets.filter((snippet) => snippet.category === category);
|
||||
@@ -64,9 +63,9 @@ export async function fetchSnippetsByCategory(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSnippetsBySource(
|
||||
export const fetchSnippetsBySource = async (
|
||||
source: "builtin" | "user"
|
||||
): Promise<BashSnippet[]> {
|
||||
): Promise<BashSnippet[]> => {
|
||||
try {
|
||||
const snippets = await loadAllSnippets();
|
||||
return snippets.filter((snippet) => snippet.source === source);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
export const cn = (...inputs: ClassValue[]) => {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
59
app/_utils/cron/files-manipulation.ts
Normal file
59
app/_utils/cron/files-manipulation.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
"use server";
|
||||
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { readHostCrontab, writeHostCrontab } from "../system/hostCrontab";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const cleanCrontabContent = async (content: string): Promise<string> => {
|
||||
const lines = content.split("\n");
|
||||
const cleanedLines: string[] = [];
|
||||
let consecutiveEmptyLines = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "") {
|
||||
consecutiveEmptyLines++;
|
||||
if (consecutiveEmptyLines <= 1) {
|
||||
cleanedLines.push("");
|
||||
}
|
||||
} else {
|
||||
consecutiveEmptyLines = 0;
|
||||
cleanedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedLines.join("\n").trim();
|
||||
}
|
||||
|
||||
export const readCronFiles = async (): Promise<string> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
|
||||
if (!isDocker) {
|
||||
try {
|
||||
const { stdout } = await execAsync('crontab -l 2>/dev/null || echo ""');
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
console.error("Error reading crontab:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return await readHostCrontab();
|
||||
}
|
||||
|
||||
export const writeCronFiles = async (content: string): Promise<boolean> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
|
||||
if (!isDocker) {
|
||||
try {
|
||||
await execAsync('echo "' + content + '" | crontab -');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error writing crontab:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return await writeHostCrontab(content);
|
||||
}
|
||||
417
app/_utils/cron/line-manipulation.ts
Normal file
417
app/_utils/cron/line-manipulation.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { CronJob } from "../system";
|
||||
|
||||
export const pauseJobInLines = (lines: string[], targetJobIndex: number): string[] => {
|
||||
const newCronEntries: string[] = [];
|
||||
let currentJobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
|
||||
newCronEntries.push("");
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("# User:") ||
|
||||
trimmedLine.startsWith("# System Crontab")
|
||||
) {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED: ")) {
|
||||
newCronEntries.push(line);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
currentJobIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
if (
|
||||
i + 1 < lines.length &&
|
||||
!lines[i + 1].trim().startsWith("#") &&
|
||||
lines[i + 1].trim()
|
||||
) {
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const comment = trimmedLine.substring(1).trim();
|
||||
const nextLine = lines[i + 1].trim();
|
||||
const pausedEntry = `# PAUSED: ${comment}\n# ${nextLine}`;
|
||||
newCronEntries.push(pausedEntry);
|
||||
i += 2;
|
||||
currentJobIndex++;
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
currentJobIndex++;
|
||||
}
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const pausedEntry = `# PAUSED:\n# ${trimmedLine}`;
|
||||
newCronEntries.push(pausedEntry);
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
}
|
||||
|
||||
currentJobIndex++;
|
||||
i++;
|
||||
}
|
||||
|
||||
return newCronEntries;
|
||||
}
|
||||
|
||||
export const resumeJobInLines = (lines: string[], targetJobIndex: number): string[] => {
|
||||
const newCronEntries: string[] = [];
|
||||
let currentJobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
|
||||
newCronEntries.push("");
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("# User:") ||
|
||||
trimmedLine.startsWith("# System Crontab")
|
||||
) {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED: ")) {
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const comment = trimmedLine.substring(10).trim();
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
const cronLine = lines[i + 1].trim().substring(2);
|
||||
const resumedEntry = comment ? `# ${comment}\n${cronLine}` : cronLine;
|
||||
newCronEntries.push(resumedEntry);
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
currentJobIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
newCronEntries.push(line);
|
||||
currentJobIndex++;
|
||||
i++;
|
||||
}
|
||||
|
||||
return newCronEntries;
|
||||
}
|
||||
|
||||
export const parseJobsFromLines = (lines: string[], user: string): CronJob[] => {
|
||||
const jobs: CronJob[] = [];
|
||||
let currentComment = "";
|
||||
let jobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("# User:") ||
|
||||
trimmedLine.startsWith("# System Crontab")
|
||||
) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED: ")) {
|
||||
const comment = trimmedLine.substring(10).trim();
|
||||
|
||||
if (i + 1 < lines.length) {
|
||||
const nextLine = lines[i + 1].trim();
|
||||
if (nextLine.startsWith("# ")) {
|
||||
const commentedCron = nextLine.substring(2);
|
||||
const parts = commentedCron.split(/\s+/);
|
||||
if (parts.length >= 6) {
|
||||
const schedule = parts.slice(0, 5).join(" ");
|
||||
const command = parts.slice(5).join(" ");
|
||||
|
||||
jobs.push({
|
||||
id: `${user}-${jobIndex}`,
|
||||
schedule,
|
||||
command,
|
||||
comment: comment || undefined,
|
||||
user,
|
||||
paused: true,
|
||||
});
|
||||
|
||||
jobIndex++;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
if (
|
||||
i + 1 < lines.length &&
|
||||
!lines[i + 1].trim().startsWith("#") &&
|
||||
lines[i + 1].trim()
|
||||
) {
|
||||
currentComment = trimmedLine.substring(1).trim();
|
||||
i++;
|
||||
continue;
|
||||
} else {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let schedule, command;
|
||||
const parts = trimmedLine.split(/(?:\s|\t)+/);
|
||||
|
||||
if (parts[0].startsWith("@")) {
|
||||
if (parts.length >= 2) {
|
||||
schedule = parts[0];
|
||||
command = trimmedLine.slice(trimmedLine.indexOf(parts[1]))
|
||||
}
|
||||
} else if (parts.length >= 6) {
|
||||
schedule = parts.slice(0, 5).join(" ");
|
||||
command = trimmedLine.slice(trimmedLine.indexOf(parts[5]))
|
||||
}
|
||||
|
||||
if (schedule && command) {
|
||||
jobs.push({
|
||||
id: `${user}-${jobIndex}`,
|
||||
schedule,
|
||||
command,
|
||||
comment: currentComment || undefined,
|
||||
user,
|
||||
paused: false,
|
||||
});
|
||||
|
||||
jobIndex++;
|
||||
currentComment = "";
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
export const deleteJobInLines = (lines: string[], targetJobIndex: number): string[] => {
|
||||
const newCronEntries: string[] = [];
|
||||
let currentJobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
|
||||
newCronEntries.push("");
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("# User:") ||
|
||||
trimmedLine.startsWith("# System Crontab")
|
||||
) {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED: ")) {
|
||||
if (currentJobIndex !== targetJobIndex) {
|
||||
newCronEntries.push(line);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
currentJobIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
if (
|
||||
i + 1 < lines.length &&
|
||||
!lines[i + 1].trim().startsWith("#") &&
|
||||
lines[i + 1].trim()
|
||||
) {
|
||||
if (currentJobIndex !== targetJobIndex) {
|
||||
newCronEntries.push(line);
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
}
|
||||
i += 2;
|
||||
currentJobIndex++;
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentJobIndex !== targetJobIndex) {
|
||||
newCronEntries.push(line);
|
||||
}
|
||||
|
||||
currentJobIndex++;
|
||||
i++;
|
||||
}
|
||||
|
||||
return newCronEntries;
|
||||
}
|
||||
|
||||
export const updateJobInLines = (
|
||||
lines: string[],
|
||||
targetJobIndex: number,
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = ""
|
||||
): string[] => {
|
||||
const newCronEntries: string[] = [];
|
||||
let currentJobIndex = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) {
|
||||
if (newCronEntries.length > 0 && newCronEntries[newCronEntries.length - 1] !== "") {
|
||||
newCronEntries.push("");
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedLine.startsWith("# User:") ||
|
||||
trimmedLine.startsWith("# System Crontab")
|
||||
) {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# PAUSED: ")) {
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const newEntry = comment
|
||||
? `# PAUSED: ${comment}\n# ${schedule} ${command}`
|
||||
: `# PAUSED:\n# ${schedule} ${command}`;
|
||||
newCronEntries.push(newEntry);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) {
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
currentJobIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
if (
|
||||
i + 1 < lines.length &&
|
||||
!lines[i + 1].trim().startsWith("#") &&
|
||||
lines[i + 1].trim()
|
||||
) {
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
newCronEntries.push(newEntry);
|
||||
i += 2;
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
newCronEntries.push(lines[i + 1]);
|
||||
i += 2;
|
||||
}
|
||||
currentJobIndex++;
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentJobIndex === targetJobIndex) {
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
newCronEntries.push(newEntry);
|
||||
} else {
|
||||
newCronEntries.push(line);
|
||||
}
|
||||
|
||||
currentJobIndex++;
|
||||
i++;
|
||||
}
|
||||
|
||||
return newCronEntries;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export interface CronExplanation {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function parseCronExpression(expression: string): CronExplanation {
|
||||
export const parseCronExpression = (expression: string): CronExplanation => {
|
||||
try {
|
||||
const cleanExpression = expression.trim();
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ScriptMetadata {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function parseMetadata(content: string): ScriptMetadata {
|
||||
const parseMetadata = (content: string): ScriptMetadata => {
|
||||
const metadata: ScriptMetadata = {};
|
||||
const lines = content.split("\n");
|
||||
|
||||
@@ -34,7 +34,7 @@ function parseMetadata(content: string): ScriptMetadata {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function scanScriptsDirectory(dirPath: string): Promise<Script[]> {
|
||||
const scanScriptsDirectory = async (dirPath: string): Promise<Script[]> => {
|
||||
const scripts: Script[] = [];
|
||||
|
||||
try {
|
||||
@@ -66,7 +66,7 @@ async function scanScriptsDirectory(dirPath: string): Promise<Script[]> {
|
||||
return scripts;
|
||||
}
|
||||
|
||||
export async function loadAllScripts(): Promise<Script[]> {
|
||||
export const loadAllScripts = async (): Promise<Script[]> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const scriptsDir = isDocker
|
||||
? "/app/scripts"
|
||||
@@ -74,7 +74,7 @@ export async function loadAllScripts(): Promise<Script[]> {
|
||||
return await scanScriptsDirectory(scriptsDir);
|
||||
}
|
||||
|
||||
export function searchScripts(scripts: Script[], query: string): Script[] {
|
||||
export const searchScripts = (scripts: Script[], query: string): Script[] => {
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return scripts.filter(
|
||||
(script) =>
|
||||
@@ -83,9 +83,9 @@ export function searchScripts(scripts: Script[], query: string): Script[] {
|
||||
);
|
||||
}
|
||||
|
||||
export function getScriptById(
|
||||
export const getScriptById = (
|
||||
scripts: Script[],
|
||||
id: string
|
||||
): Script | undefined {
|
||||
): Script | undefined => {
|
||||
return scripts.find((script) => script.id === id);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ const SCRIPTS_DIR = async () => {
|
||||
return join(process.cwd(), "scripts");
|
||||
};
|
||||
|
||||
export async function getScriptPath(filename: string): Promise<string> {
|
||||
export const getScriptPath = async (filename: string): Promise<string> => {
|
||||
return join(await SCRIPTS_DIR(), filename);
|
||||
}
|
||||
|
||||
export async function getHostScriptPath(filename: string): Promise<string> {
|
||||
export const getHostScriptPath = async (filename: string): Promise<string> => {
|
||||
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
|
||||
|
||||
const hostScriptsDir = join(hostProjectDir, "scripts");
|
||||
|
||||
@@ -20,7 +20,7 @@ interface SnippetMetadata {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
function parseMetadata(content: string): SnippetMetadata {
|
||||
const parseMetadata = (content: string): SnippetMetadata => {
|
||||
const metadata: SnippetMetadata = {};
|
||||
const lines = content.split("\n");
|
||||
|
||||
@@ -53,7 +53,7 @@ function parseMetadata(content: string): SnippetMetadata {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function extractTemplate(content: string): string {
|
||||
const extractTemplate = (content: string): string => {
|
||||
const lines = content.split("\n");
|
||||
const templateLines: string[] = [];
|
||||
let inTemplate = false;
|
||||
@@ -75,10 +75,10 @@ function extractTemplate(content: string): string {
|
||||
return templateLines.join("\n").trim();
|
||||
}
|
||||
|
||||
async function scanSnippetDirectory(
|
||||
const scanSnippetDirectory = async (
|
||||
dirPath: string,
|
||||
source: "builtin" | "user"
|
||||
): Promise<BashSnippet[]> {
|
||||
): Promise<BashSnippet[]> => {
|
||||
const snippets: BashSnippet[] = [];
|
||||
|
||||
try {
|
||||
@@ -117,7 +117,7 @@ async function scanSnippetDirectory(
|
||||
return snippets;
|
||||
}
|
||||
|
||||
export async function loadAllSnippets(): Promise<BashSnippet[]> {
|
||||
export const loadAllSnippets = async (): Promise<BashSnippet[]> => {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
|
||||
let builtinSnippetsPath: string;
|
||||
@@ -141,10 +141,10 @@ export async function loadAllSnippets(): Promise<BashSnippet[]> {
|
||||
return [...builtinSnippets, ...userSnippets];
|
||||
}
|
||||
|
||||
export function searchBashSnippets(
|
||||
export const searchBashSnippets = (
|
||||
snippets: BashSnippet[],
|
||||
query: string
|
||||
): BashSnippet[] {
|
||||
): BashSnippet[] => {
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return snippets.filter(
|
||||
(snippet) =>
|
||||
@@ -155,14 +155,14 @@ export function searchBashSnippets(
|
||||
);
|
||||
}
|
||||
|
||||
export function getSnippetCategories(snippets: BashSnippet[]): string[] {
|
||||
export const getSnippetCategories = (snippets: BashSnippet[]): string[] => {
|
||||
const categories = new Set(snippets.map((snippet) => snippet.category));
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
|
||||
export function getSnippetById(
|
||||
export const getSnippetById = (
|
||||
snippets: BashSnippet[],
|
||||
id: string
|
||||
): BashSnippet | undefined {
|
||||
): BashSnippet | undefined => {
|
||||
return snippets.find((snippet) => snippet.id === id);
|
||||
}
|
||||
|
||||
@@ -3,5 +3,8 @@ export {
|
||||
addCronJob,
|
||||
deleteCronJob,
|
||||
updateCronJob,
|
||||
type CronJob
|
||||
pauseCronJob,
|
||||
resumeCronJob,
|
||||
cleanupCrontab,
|
||||
type CronJob,
|
||||
} from "./system/cron";
|
||||
|
||||
@@ -1,229 +1,228 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { readHostCrontab, writeHostCrontab } from "./hostCrontab";
|
||||
import {
|
||||
readAllHostCrontabs,
|
||||
writeHostCrontabForUser,
|
||||
} from "./hostCrontab";
|
||||
import { parseJobsFromLines, deleteJobInLines, updateJobInLines, pauseJobInLines, resumeJobInLines } from "../cron/line-manipulation";
|
||||
import { cleanCrontabContent, readCronFiles, writeCronFiles } from "../cron/files-manipulation";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
schedule: string;
|
||||
command: string;
|
||||
comment?: string;
|
||||
id: string;
|
||||
schedule: string;
|
||||
command: string;
|
||||
comment?: string;
|
||||
user: string;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
async function readCronFiles(): Promise<string> {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
const isDocker = (): boolean => process.env.DOCKER === "true";
|
||||
|
||||
if (!isDocker) {
|
||||
try {
|
||||
const { stdout } = await execAsync('crontab -l 2>/dev/null || echo ""');
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
console.error("Error reading crontab:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const readUserCrontab = async (user: string): Promise<string> => {
|
||||
if (isDocker()) {
|
||||
const userCrontabs = await readAllHostCrontabs();
|
||||
const targetUserCrontab = userCrontabs.find((uc) => uc.user === user);
|
||||
return targetUserCrontab?.content || "";
|
||||
} else {
|
||||
const { stdout } = await execAsync(
|
||||
`crontab -l -u ${user} 2>/dev/null || echo ""`
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
};
|
||||
|
||||
return await readHostCrontab();
|
||||
}
|
||||
|
||||
async function writeCronFiles(content: string): Promise<boolean> {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
|
||||
if (!isDocker) {
|
||||
try {
|
||||
await execAsync('echo "' + content + '" | crontab -');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error writing crontab:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return await writeHostCrontab(content);
|
||||
}
|
||||
|
||||
export async function getCronJobs(): Promise<CronJob[]> {
|
||||
const writeUserCrontab = async (user: string, content: string): Promise<boolean> => {
|
||||
if (isDocker()) {
|
||||
return await writeHostCrontabForUser(user, content);
|
||||
} else {
|
||||
try {
|
||||
const cronContent = await readCronFiles();
|
||||
|
||||
if (!cronContent.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = cronContent.split("\n");
|
||||
const jobs: CronJob[] = [];
|
||||
let currentComment = "";
|
||||
let currentUser = "";
|
||||
let jobIndex = 0;
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) return;
|
||||
|
||||
if (trimmedLine.startsWith("# User: ")) {
|
||||
currentUser = trimmedLine.substring(8).trim();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# System Crontab")) {
|
||||
currentUser = "system";
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("#")) {
|
||||
currentComment = trimmedLine.substring(1).trim();
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = trimmedLine.split(/\s+/);
|
||||
if (parts.length >= 6) {
|
||||
const schedule = parts.slice(0, 5).join(" ");
|
||||
const command = parts.slice(5).join(" ");
|
||||
|
||||
jobs.push({
|
||||
id: `unix-${jobIndex}`,
|
||||
schedule,
|
||||
command,
|
||||
comment: currentComment,
|
||||
});
|
||||
|
||||
currentComment = "";
|
||||
jobIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
return jobs;
|
||||
await execAsync(`echo '${content}' | crontab -u ${user} -`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error getting cron jobs:", error);
|
||||
return [];
|
||||
console.error(`Error writing crontab for user ${user}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getAllUsers = async (): Promise<{ user: string; content: string }[]> => {
|
||||
if (isDocker()) {
|
||||
return await readAllHostCrontabs();
|
||||
} else {
|
||||
const { getAllTargetUsers } = await import("./hostCrontab");
|
||||
const users = await getAllTargetUsers();
|
||||
const results: { user: string; content: string }[] = [];
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`crontab -l -u ${user} 2>/dev/null || echo ""`
|
||||
);
|
||||
results.push({ user, content: stdout });
|
||||
} catch (error) {
|
||||
console.error(`Error reading crontab for user ${user}:`, error);
|
||||
results.push({ user, content: "" });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCronJobs = async (): Promise<CronJob[]> => {
|
||||
try {
|
||||
const userCrontabs = await getAllUsers();
|
||||
let allJobs: CronJob[] = [];
|
||||
|
||||
for (const { user, content } of userCrontabs) {
|
||||
if (!content.trim()) continue;
|
||||
|
||||
const lines = content.split("\n");
|
||||
const jobs = parseJobsFromLines(lines, user);
|
||||
allJobs.push(...jobs);
|
||||
}
|
||||
|
||||
return allJobs;
|
||||
} catch (error) {
|
||||
console.error("Error getting cron jobs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCronJob(
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = ""
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const cronContent = await readCronFiles();
|
||||
export const addCronJob = async (
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = "",
|
||||
user?: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
if (user) {
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
let newCron;
|
||||
if (cronContent.trim() === "") {
|
||||
newCron = newEntry;
|
||||
} else {
|
||||
const existingContent = cronContent.trim();
|
||||
newCron = await cleanCrontabContent(existingContent + "\n" + newEntry);
|
||||
}
|
||||
|
||||
let newCron;
|
||||
if (cronContent.trim() === "") {
|
||||
newCron = newEntry;
|
||||
} else {
|
||||
const existingContent = cronContent.endsWith('\n') ? cronContent : cronContent + '\n';
|
||||
newCron = existingContent + newEntry;
|
||||
}
|
||||
return await writeUserCrontab(user, newCron);
|
||||
} else {
|
||||
const cronContent = await readCronFiles();
|
||||
|
||||
return await writeCronFiles(newCron);
|
||||
} catch (error) {
|
||||
console.error("Error adding cron job:", error);
|
||||
return false;
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
|
||||
let newCron;
|
||||
if (cronContent.trim() === "") {
|
||||
newCron = newEntry;
|
||||
} else {
|
||||
const existingContent = cronContent.trim();
|
||||
newCron = await cleanCrontabContent(existingContent + "\n" + newEntry);
|
||||
}
|
||||
|
||||
return await writeCronFiles(newCron);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCronJob(id: string): Promise<boolean> {
|
||||
try {
|
||||
const cronContent = await readCronFiles();
|
||||
const lines = cronContent.split("\n");
|
||||
let currentComment = "";
|
||||
let cronEntries: string[] = [];
|
||||
let jobIndex = 0;
|
||||
let targetJobIndex = parseInt(id.replace("unix-", ""));
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
if (trimmedLine.startsWith("# User:") || trimmedLine.startsWith("# System Crontab")) {
|
||||
cronEntries.push(trimmedLine);
|
||||
} else if (trimmedLine.startsWith("#")) {
|
||||
if (i + 1 < lines.length && !lines[i + 1].trim().startsWith("#") && lines[i + 1].trim()) {
|
||||
currentComment = trimmedLine;
|
||||
} else {
|
||||
cronEntries.push(trimmedLine);
|
||||
}
|
||||
} else {
|
||||
if (jobIndex !== targetJobIndex) {
|
||||
const entryWithComment = currentComment
|
||||
? `${currentComment}\n${trimmedLine}`
|
||||
: trimmedLine;
|
||||
cronEntries.push(entryWithComment);
|
||||
}
|
||||
jobIndex++;
|
||||
currentComment = "";
|
||||
}
|
||||
}
|
||||
|
||||
const newCron = cronEntries.join("\n") + "\n";
|
||||
await writeCronFiles(newCron);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error deleting cron job:", error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error adding cron job:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCronJob(
|
||||
id: string,
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = ""
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const cronContent = await readCronFiles();
|
||||
const lines = cronContent.split("\n");
|
||||
let currentComment = "";
|
||||
let cronEntries: string[] = [];
|
||||
let jobIndex = 0;
|
||||
let targetJobIndex = parseInt(id.replace("unix-", ""));
|
||||
export const deleteCronJob = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const lines = cronContent.split("\n");
|
||||
const newCronEntries = deleteJobInLines(lines, jobIndex);
|
||||
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
|
||||
|
||||
if (!trimmedLine) continue;
|
||||
return await writeUserCrontab(user, newCron);
|
||||
} catch (error) {
|
||||
console.error("Error deleting cron job:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith("# User:") || trimmedLine.startsWith("# System Crontab")) {
|
||||
cronEntries.push(trimmedLine);
|
||||
} else if (trimmedLine.startsWith("#")) {
|
||||
if (i + 1 < lines.length && !lines[i + 1].trim().startsWith("#") && lines[i + 1].trim()) {
|
||||
currentComment = trimmedLine;
|
||||
} else {
|
||||
cronEntries.push(trimmedLine);
|
||||
}
|
||||
} else {
|
||||
if (jobIndex === targetJobIndex) {
|
||||
const newEntry = comment
|
||||
? `# ${comment}\n${schedule} ${command}`
|
||||
: `${schedule} ${command}`;
|
||||
cronEntries.push(newEntry);
|
||||
} else {
|
||||
const entryWithComment = currentComment
|
||||
? `${currentComment}\n${trimmedLine}`
|
||||
: trimmedLine;
|
||||
cronEntries.push(entryWithComment);
|
||||
}
|
||||
jobIndex++;
|
||||
currentComment = "";
|
||||
}
|
||||
}
|
||||
export const updateCronJob = async (
|
||||
id: string,
|
||||
schedule: string,
|
||||
command: string,
|
||||
comment: string = ""
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
|
||||
const newCron = cronEntries.join("\n") + "\n";
|
||||
await writeCronFiles(newCron);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error updating cron job:", error);
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const lines = cronContent.split("\n");
|
||||
const newCronEntries = updateJobInLines(lines, jobIndex, schedule, command, comment);
|
||||
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
|
||||
|
||||
return await writeUserCrontab(user, newCron);
|
||||
} catch (error) {
|
||||
console.error("Error updating cron job:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const pauseCronJob = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const lines = cronContent.split("\n");
|
||||
const newCronEntries = pauseJobInLines(lines, jobIndex);
|
||||
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
|
||||
|
||||
return await writeUserCrontab(user, newCron);
|
||||
} catch (error) {
|
||||
console.error("Error pausing cron job:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const resumeCronJob = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const [user, jobIndexStr] = id.split("-");
|
||||
const jobIndex = parseInt(jobIndexStr);
|
||||
|
||||
const cronContent = await readUserCrontab(user);
|
||||
const lines = cronContent.split("\n");
|
||||
const newCronEntries = resumeJobInLines(lines, jobIndex);
|
||||
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
|
||||
|
||||
return await writeUserCrontab(user, newCron);
|
||||
} catch (error) {
|
||||
console.error("Error resuming cron job:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanupCrontab = async (): Promise<boolean> => {
|
||||
try {
|
||||
const userCrontabs = await getAllUsers();
|
||||
|
||||
for (const { user, content } of userCrontabs) {
|
||||
if (!content.trim()) continue;
|
||||
|
||||
const cleanedContent = await cleanCrontabContent(content);
|
||||
await writeUserCrontab(user, cleanedContent);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error cleaning crontab:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
async function execHostCrontab(command: string): Promise<string> {
|
||||
export interface UserInfo {
|
||||
username: string;
|
||||
uid: number;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
const execHostCrontab = async (command: string): Promise<string> => {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`
|
||||
@@ -15,7 +21,7 @@ async function execHostCrontab(command: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getTargetUser(): Promise<string> {
|
||||
const getTargetUser = async (): Promise<string> => {
|
||||
try {
|
||||
if (process.env.HOST_CRONTAB_USER) {
|
||||
return process.env.HOST_CRONTAB_USER;
|
||||
@@ -60,7 +66,36 @@ async function getTargetUser(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function readHostCrontab(): Promise<string> {
|
||||
export const getAllTargetUsers = async (): Promise<string[]> => {
|
||||
try {
|
||||
if (process.env.HOST_CRONTAB_USER) {
|
||||
return process.env.HOST_CRONTAB_USER.split(",").map((u) => u.trim());
|
||||
}
|
||||
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
if (isDocker) {
|
||||
const singleUser = await getTargetUser();
|
||||
return [singleUser];
|
||||
} else {
|
||||
try {
|
||||
const { stdout } = await execAsync("ls /var/spool/cron/crontabs/");
|
||||
const users = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((user) => user.trim());
|
||||
return users.length > 0 ? users : ["root"];
|
||||
} catch (error) {
|
||||
console.error("Error detecting users from crontabs directory:", error);
|
||||
return ["root"];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting all target users:", error);
|
||||
return ["root"];
|
||||
}
|
||||
}
|
||||
|
||||
export const readHostCrontab = async (): Promise<string> => {
|
||||
try {
|
||||
const user = await getTargetUser();
|
||||
return await execHostCrontab(
|
||||
@@ -72,7 +107,33 @@ export async function readHostCrontab(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeHostCrontab(content: string): Promise<boolean> {
|
||||
export const readAllHostCrontabs = async (): Promise<
|
||||
{ user: string; content: string }[]
|
||||
> => {
|
||||
try {
|
||||
const users = await getAllTargetUsers();
|
||||
const results: { user: string; content: string }[] = [];
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
const content = await execHostCrontab(
|
||||
`crontab -l -u ${user} 2>/dev/null || echo ""`
|
||||
);
|
||||
results.push({ user, content });
|
||||
} catch (error) {
|
||||
console.warn(`Error reading crontab for user ${user}:`, error);
|
||||
results.push({ user, content: "" });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("Error reading all host crontabs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const writeHostCrontab = async (content: string): Promise<boolean> => {
|
||||
try {
|
||||
const user = await getTargetUser();
|
||||
let finalContent = content;
|
||||
@@ -90,3 +151,61 @@ export async function writeHostCrontab(content: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const writeHostCrontabForUser = async (
|
||||
user: string,
|
||||
content: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
let finalContent = content;
|
||||
if (!finalContent.endsWith("\n")) {
|
||||
finalContent += "\n";
|
||||
}
|
||||
|
||||
const base64Content = Buffer.from(finalContent).toString("base64");
|
||||
await execHostCrontab(
|
||||
`echo '${base64Content}' | base64 -d | crontab -u ${user} -`
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing host crontab for user ${user}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserInfo(username: string): Promise<UserInfo | null> {
|
||||
try {
|
||||
const isDocker = process.env.DOCKER === "true";
|
||||
|
||||
if (isDocker) {
|
||||
const uidResult = await execHostCrontab(`id -u ${username}`);
|
||||
const gidResult = await execHostCrontab(`id -g ${username}`);
|
||||
|
||||
const uid = parseInt(uidResult.trim());
|
||||
const gid = parseInt(gidResult.trim());
|
||||
|
||||
if (isNaN(uid) || isNaN(gid)) {
|
||||
console.error(`Invalid UID/GID for user ${username}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { username, uid, gid };
|
||||
} else {
|
||||
const { stdout } = await execAsync(`id -u ${username}`);
|
||||
const uid = parseInt(stdout.trim());
|
||||
|
||||
const { stdout: gidStdout } = await execAsync(`id -g ${username}`);
|
||||
const gid = parseInt(gidStdout.trim());
|
||||
|
||||
if (isNaN(uid) || isNaN(gid)) {
|
||||
console.error(`Invalid UID/GID for user ${username}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { username, uid, gid };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting user info for ${username}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ export default async function Home() {
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
<div className="flex items-center gap-2 fixed bottom-4 left-4 lg:right-4 lg:left-auto">
|
||||
<div className="flex items-center gap-2 fixed bottom-4 left-4 lg:right-4 lg:left-auto z-10 bg-background rounded-lg">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
cronjob-manager:
|
||||
image: ghcr.io/fccview/cronmaster:main
|
||||
image: ghcr.io/fccview/cronmaster:1.3.0
|
||||
container_name: cronmaster
|
||||
user: "root"
|
||||
ports:
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||
# If docker struggles to find your crontab user, update this variable with it.
|
||||
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
|
||||
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=fccview,root,user1,user2
|
||||
# - HOST_CRONTAB_USER=fccview
|
||||
volumes:
|
||||
# Mount Docker socket to execute commands on host
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user