13 Commits
BUG-3 ... 1.3.1

Author SHA1 Message Date
fccview
7fc8cb9edb update readme 2025-08-31 19:20:10 +01:00
fccview
4dfdf8fc53 Merge pull request #27 from fccview/BUG-4
Fix editing jobs with description too similar
2025-08-31 19:16:55 +01:00
fccview
8cfc000893 Fix editing jobs with description too similar 2025-08-31 19:16:19 +01:00
fccview
1dde8f839e fix readme 2025-08-27 20:23:24 +01:00
fccview
2b7d591a95 Merge pull request #20 from fccview/feature/multi-user-support
Feature/multi user support
2025-08-27 20:21:59 +01:00
fccview
c0a9a74d7e syntax 2025-08-27 08:00:12 +01:00
fccview
376147fda0 Fix potential permission issues on manual runs 2025-08-27 07:57:31 +01:00
fccview
9445cdeebf Update app/_utils/cron/line-manipulation.ts
Co-authored-by: DVDAndroid <6277172+DVDAndroid@users.noreply.github.com>
2025-08-27 07:44:07 +01:00
fccview
170ea674c4 standardize codebase 2025-08-26 21:03:33 +01:00
fccview
80bd2e713f fix readme 2025-08-26 19:48:46 +01:00
fccview
801bcf22a2 Multi user support and enhancements 2025-08-26 19:46:06 +01:00
fccview
8fd7d0d80f Start working on multi users functionalities 2025-08-26 16:44:05 +01:00
fccview
95f113faa6 Merge pull request #19 from fccview/BUG-3
Fix script path bug and add support for pinned versions
2025-08-26 13:47:10 +01:00
52 changed files with 1442 additions and 388 deletions

View File

@@ -2,7 +2,7 @@ name: Docker
on:
push:
branches: ["main", "legacy"]
branches: ["main", "legacy", "feature/*", "bugfix/*"]
tags: ["*"]
pull_request:
branches: ["main"]

4
.gitignore vendored
View File

@@ -10,4 +10,6 @@ node_modules
.next
.vscode
.DS_Store
.cursorignore
.cursorignore
tsconfig.tsbuildinfo
docker-compose.test.yml

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,5 +3,8 @@ export {
addCronJob,
deleteCronJob,
updateCronJob,
type CronJob
pauseCronJob,
resumeCronJob,
cleanupCrontab,
type CronJob,
} from "./system/cron";

View File

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

View File

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

View File

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

View File

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

View File

File diff suppressed because one or more lines are too long