mirror of
https://github.com/fccview/cronmaster.git
synced 2026-01-01 10:29:02 -05:00
Compare commits
16 Commits
bugfix/rep
...
BUG-4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cfc000893 | ||
|
|
1dde8f839e | ||
|
|
2b7d591a95 | ||
|
|
c0a9a74d7e | ||
|
|
376147fda0 | ||
|
|
9445cdeebf | ||
|
|
170ea674c4 | ||
|
|
80bd2e713f | ||
|
|
801bcf22a2 | ||
|
|
8fd7d0d80f | ||
|
|
95f113faa6 | ||
|
|
2a437f3db8 | ||
|
|
47e19246ce | ||
|
|
29917a4cad | ||
|
|
ac31906166 | ||
|
|
b267fb9ce6 |
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -3,7 +3,7 @@ name: Docker
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main", "legacy", "feature/*", "bugfix/*"]
|
branches: ["main", "legacy", "feature/*", "bugfix/*"]
|
||||||
tags: ["v*.*.*"]
|
tags: ["*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,4 +10,6 @@ node_modules
|
|||||||
.next
|
.next
|
||||||
.vscode
|
.vscode
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.cursorignore
|
.cursorignore
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
docker-compose.test.yml
|
||||||
27
README.md
27
README.md
@@ -49,19 +49,21 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
|
|||||||
```bash
|
```bash
|
||||||
services:
|
services:
|
||||||
cronjob-manager:
|
cronjob-manager:
|
||||||
image: ghcr.io/fccview/cronmaster:main
|
image: ghcr.io/fccview/cronmaster:1.3.0
|
||||||
container_name: cronmaster-test
|
container_name: cronmaster
|
||||||
user: "root"
|
user: "root"
|
||||||
ports:
|
ports:
|
||||||
# Feel free to change port, 3000 is very common so I like to map it to something else
|
# Feel free to change port, 3000 is very common so I like to map it to something else
|
||||||
- "40124:3000"
|
- "40123:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DOCKER=true
|
- DOCKER=true
|
||||||
|
# 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
|
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||||
- NEXT_PUBLIC_HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
|
||||||
# If docker struggles to find your crontab user, update this variable with it.
|
# 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/
|
# 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
|
# - HOST_CRONTAB_USER=fccview
|
||||||
volumes:
|
volumes:
|
||||||
# Mount Docker socket to execute commands on host
|
# 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.
|
# 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
|
# 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 NEXT_PUBLIC_HOST_PROJECT_DIR variable set above)
|
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
|
||||||
- ./scripts:/app/scripts
|
- ./scripts:/app/scripts
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./snippets:/app/snippets
|
- ./snippets:/app/snippets
|
||||||
@@ -82,7 +84,7 @@ services:
|
|||||||
init: true
|
init: true
|
||||||
|
|
||||||
# Default platform is set to amd64, uncomment to use arm64.
|
# Default platform is set to amd64, uncomment to use arm64.
|
||||||
#platform: linux/arm64
|
#platform: linux/arm64
|
||||||
```
|
```
|
||||||
|
|
||||||
### ARM64 Support
|
### ARM64 Support
|
||||||
@@ -132,7 +134,7 @@ The following environment variables can be configured:
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------------------- | ------- | ------------------------------------------------------------------------------------------- |
|
| ----------------------------------- | ------- | ------------------------------------------------------------------------------------------- |
|
||||||
| `NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL` | `30000` | Clock update interval in milliseconds (30 seconds) |
|
| `NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL` | `30000` | Clock update interval in milliseconds (30 seconds) |
|
||||||
| `NEXT_PUBLIC_HOST_PROJECT_DIR` | `N/A` | Mandatory variable to make sure cron runs on the right path. |
|
| `HOST_PROJECT_DIR` | `N/A` | Mandatory variable to make sure cron runs on the right path. |
|
||||||
| `DOCKER` | `false` | ONLY set this to true if you are runnign the app via docker, in the docker-compose.yml file |
|
| `DOCKER` | `false` | ONLY set this to true if you are runnign the app via docker, in the docker-compose.yml file |
|
||||||
|
|
||||||
**Example**: To change the clock update interval to 60 seconds:
|
**Example**: To change the clock update interval to 60 seconds:
|
||||||
@@ -144,14 +146,14 @@ NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=60000 docker-compose up
|
|||||||
**Example**: Your `docker-compose.yml` file or repository are in `~/homelab/cronmaster/`
|
**Example**: Your `docker-compose.yml` file or repository are in `~/homelab/cronmaster/`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NEXT_PUBLIC_HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
|
HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
|
||||||
```
|
```
|
||||||
|
|
||||||
### Important Notes for Docker
|
### Important Notes for Docker
|
||||||
|
|
||||||
- Root user is required for cron operations and direct file access. There is no way around this, if you don't feel comfortable in running it as root feel free to run the app locally with `yarn install`, `yarn build` and `yarn start`
|
- Root user is required for cron operations and direct file access. There is no way around this, if you don't feel comfortable in running it as root feel free to run the app locally with `yarn install`, `yarn build` and `yarn start`
|
||||||
- Crontab files are accessed directly via file system mounts at `/host/cron/crontabs` and `/host/crontab` for real-time reading and writing
|
- Crontab files are accessed directly via file system mounts at `/host/cron/crontabs` and `/host/crontab` for real-time reading and writing
|
||||||
- `NEXT_PUBLIC_HOST_PROJECT_DIR` is required in order for the scripts created within the app to run properly
|
- `HOST_PROJECT_DIR` is required in order for the scripts created within the app to run properly
|
||||||
- The `DOCKER=true` environment variable enables direct file access mode for crontab operations. This is REQUIRED when running the application in docker mode.
|
- The `DOCKER=true` environment variable enables direct file access mode for crontab operations. This is REQUIRED when running the application in docker mode.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -214,7 +216,7 @@ The application uses standard cron format: `* * * * *`
|
|||||||
|
|
||||||
## Community shouts
|
## Community shouts
|
||||||
|
|
||||||
I would like to thank the following members for raising issues and help test/debug them!
|
I would like to thank the following members for raising issues and help test/debug them!
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -235,6 +237,11 @@ 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>
|
<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>
|
</td>
|
||||||
</tr>
|
</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>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ interface BashEditorProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BashEditor({
|
export const BashEditor = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "#!/bin/bash\n# Your bash script here\necho 'Hello World'",
|
placeholder = "#!/bin/bash\n# Your bash script here\necho 'Hello World'",
|
||||||
className = "",
|
className = "",
|
||||||
label = "Bash Script",
|
label = "Bash Script",
|
||||||
}: BashEditorProps) {
|
}: BashEditorProps) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const editorViewRef = useRef<EditorView | null>(null);
|
const editorViewRef = useRef<EditorView | null>(null);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const categoryIcons = {
|
|||||||
"Custom Scripts": Code,
|
"Custom Scripts": Code,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BashSnippetHelper({ onInsertSnippet }: BashSnippetHelperProps) {
|
export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) => {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ interface CronExpressionHelperProps {
|
|||||||
showPatterns?: boolean;
|
showPatterns?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CronExpressionHelper({
|
export const CronExpressionHelper = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "* * * * *",
|
placeholder = "* * * * *",
|
||||||
className = "",
|
className = "",
|
||||||
showPatterns = true,
|
showPatterns = true,
|
||||||
}: CronExpressionHelperProps) {
|
}: CronExpressionHelperProps) => {
|
||||||
const [explanation, setExplanation] = useState<CronExplanation | null>(null);
|
const [explanation, setExplanation] = useState<CronExplanation | null>(null);
|
||||||
const [showPatternsPanel, setShowPatternsPanel] = useState(false);
|
const [showPatternsPanel, setShowPatternsPanel] = useState(false);
|
||||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|||||||
@@ -2,19 +2,33 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/Card";
|
||||||
import { Button } from "./ui/Button";
|
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 { CronJob } from "@/app/_utils/system";
|
||||||
import {
|
import {
|
||||||
removeCronJob,
|
removeCronJob,
|
||||||
editCronJob,
|
editCronJob,
|
||||||
createCronJob,
|
createCronJob,
|
||||||
cloneCronJob,
|
cloneCronJob,
|
||||||
|
pauseCronJobAction,
|
||||||
|
resumeCronJobAction,
|
||||||
|
runCronJob,
|
||||||
} from "@/app/_server/actions/cronjobs";
|
} from "@/app/_server/actions/cronjobs";
|
||||||
import { useState } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { CreateTaskModal } from "./modals/CreateTaskModal";
|
import { CreateTaskModal } from "./modals/CreateTaskModal";
|
||||||
import { EditTaskModal } from "./modals/EditTaskModal";
|
import { EditTaskModal } from "./modals/EditTaskModal";
|
||||||
import { DeleteTaskModal } from "./modals/DeleteTaskModal";
|
import { DeleteTaskModal } from "./modals/DeleteTaskModal";
|
||||||
import { CloneTaskModal } from "./modals/CloneTaskModal";
|
import { CloneTaskModal } from "./modals/CloneTaskModal";
|
||||||
|
import { UserFilter } from "./ui/UserFilter";
|
||||||
import { type Script } from "@/app/_server/actions/scripts";
|
import { type Script } from "@/app/_server/actions/scripts";
|
||||||
import { showToast } from "./ui/Toast";
|
import { showToast } from "./ui/Toast";
|
||||||
|
|
||||||
@@ -23,7 +37,7 @@ interface CronJobListProps {
|
|||||||
scripts: Script[];
|
scripts: Script[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const [editingJob, setEditingJob] = useState<CronJob | null>(null);
|
const [editingJob, setEditingJob] = useState<CronJob | null>(null);
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
@@ -33,6 +47,24 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
|||||||
const [jobToDelete, setJobToDelete] = useState<CronJob | null>(null);
|
const [jobToDelete, setJobToDelete] = useState<CronJob | null>(null);
|
||||||
const [jobToClone, setJobToClone] = useState<CronJob | null>(null);
|
const [jobToClone, setJobToClone] = useState<CronJob | null>(null);
|
||||||
const [isCloning, setIsCloning] = useState(false);
|
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({
|
const [editForm, setEditForm] = useState({
|
||||||
schedule: "",
|
schedule: "",
|
||||||
command: "",
|
command: "",
|
||||||
@@ -43,8 +75,14 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
|||||||
command: "",
|
command: "",
|
||||||
comment: "",
|
comment: "",
|
||||||
selectedScriptId: null as string | null,
|
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) => {
|
const handleDelete = async (id: string) => {
|
||||||
setDeletingId(id);
|
setDeletingId(id);
|
||||||
try {
|
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) => {
|
const confirmDelete = (job: CronJob) => {
|
||||||
setJobToDelete(job);
|
setJobToDelete(job);
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -135,11 +229,13 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
|||||||
|
|
||||||
const handleNewCronSubmit = async (e: React.FormEvent) => {
|
const handleNewCronSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("schedule", newCronForm.schedule);
|
formData.append("schedule", newCronForm.schedule);
|
||||||
formData.append("command", newCronForm.command);
|
formData.append("command", newCronForm.command);
|
||||||
formData.append("comment", newCronForm.comment);
|
formData.append("comment", newCronForm.comment);
|
||||||
|
formData.append("user", newCronForm.user);
|
||||||
if (newCronForm.selectedScriptId) {
|
if (newCronForm.selectedScriptId) {
|
||||||
formData.append("selectedScriptId", newCronForm.selectedScriptId);
|
formData.append("selectedScriptId", newCronForm.selectedScriptId);
|
||||||
}
|
}
|
||||||
@@ -152,6 +248,7 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
|||||||
command: "",
|
command: "",
|
||||||
comment: "",
|
comment: "",
|
||||||
selectedScriptId: null,
|
selectedScriptId: null,
|
||||||
|
user: "",
|
||||||
});
|
});
|
||||||
showToast("success", "Cron job created successfully");
|
showToast("success", "Cron job created successfully");
|
||||||
} else {
|
} else {
|
||||||
@@ -180,8 +277,9 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
|||||||
Scheduled Tasks
|
Scheduled Tasks
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{cronJobs.length} scheduled job
|
{filteredJobs.length} of {cronJobs.length} scheduled job
|
||||||
{cronJobs.length !== 1 ? "s" : ""}
|
{filteredJobs.length !== 1 ? "s" : ""}
|
||||||
|
{selectedUser && ` for ${selectedUser}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,17 +293,28 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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="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">
|
<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" />
|
<Clock className="h-10 w-10 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold mb-3 brand-gradient">
|
<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>
|
</h3>
|
||||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||||
Create your first scheduled task to automate your system
|
{selectedUser
|
||||||
operations and boost productivity.
|
? `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>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsNewCronModalOpen(true)}
|
onClick={() => setIsNewCronModalOpen(true)}
|
||||||
@@ -218,13 +327,13 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{cronJobs.map((job) => (
|
{filteredJobs.map((job) => (
|
||||||
<div
|
<div
|
||||||
key={job.id}
|
key={job.id}
|
||||||
className="glass-card p-4 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors"
|
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 flex-col sm:flex-row sm:items-start gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 order-2 lg:order-1">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<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">
|
<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}
|
{job.schedule}
|
||||||
@@ -239,6 +348,18 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{job.comment && (
|
||||||
<p
|
<p
|
||||||
className="text-xs text-muted-foreground italic truncate"
|
className="text-xs text-muted-foreground italic truncate"
|
||||||
@@ -249,7 +370,22 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -270,6 +406,29 @@ export function CronJobList({ cronJobs, scripts }: CronJobListProps) {
|
|||||||
>
|
>
|
||||||
<Files className="h-3 w-3" />
|
<Files className="h-3 w-3" />
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ interface ScriptsManagerProps {
|
|||||||
scripts: Script[];
|
scripts: Script[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScriptsManager({
|
export const ScriptsManager = ({
|
||||||
scripts: initialScripts,
|
scripts: initialScripts,
|
||||||
}: ScriptsManagerProps) {
|
}: ScriptsManagerProps) => {
|
||||||
const [scripts, setScripts] = useState<Script[]>(initialScripts);
|
const [scripts, setScripts] = useState<Script[]>(initialScripts);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ interface SystemInfoCardProps {
|
|||||||
systemInfo: SystemInfoType;
|
systemInfo: SystemInfoType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SystemInfoCard({
|
export const SystemInfoCard = ({
|
||||||
systemInfo: initialSystemInfo,
|
systemInfo: initialSystemInfo,
|
||||||
}: SystemInfoCardProps) {
|
}: SystemInfoCardProps) => {
|
||||||
const [currentTime, setCurrentTime] = useState<string>("");
|
const [currentTime, setCurrentTime] = useState<string>("");
|
||||||
const [systemInfo, setSystemInfo] =
|
const [systemInfo, setSystemInfo] =
|
||||||
useState<SystemInfoType>(initialSystemInfo);
|
useState<SystemInfoType>(initialSystemInfo);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface TabbedInterfaceProps {
|
|||||||
scripts: Script[];
|
scripts: Script[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabbedInterface({ cronJobs, scripts }: TabbedInterfaceProps) {
|
export const TabbedInterface = ({ cronJobs, scripts }: TabbedInterfaceProps) => {
|
||||||
const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">(
|
const [activeTab, setActiveTab] = useState<"cronjobs" | "scripts">(
|
||||||
"cronjobs"
|
"cronjobs"
|
||||||
);
|
);
|
||||||
@@ -23,11 +23,10 @@ export function TabbedInterface({ cronJobs, scripts }: TabbedInterfaceProps) {
|
|||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("cronjobs")}
|
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 ${
|
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"
|
||||||
activeTab === "cronjobs"
|
|
||||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
Cron Jobs
|
Cron Jobs
|
||||||
@@ -37,11 +36,10 @@ export function TabbedInterface({ cronJobs, scripts }: TabbedInterfaceProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("scripts")}
|
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 ${
|
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"
|
||||||
activeTab === "scripts"
|
|
||||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
Scripts
|
Scripts
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Copy, FileText } from "lucide-react";
|
import { Copy } from "lucide-react";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import { Modal } from "../ui/Modal";
|
import { Modal } from "../ui/Modal";
|
||||||
import { Input } from "../ui/Input";
|
import { Input } from "../ui/Input";
|
||||||
@@ -15,18 +15,18 @@ interface CloneScriptModalProps {
|
|||||||
isCloning: boolean;
|
isCloning: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CloneScriptModal({
|
export const CloneScriptModal = ({
|
||||||
script,
|
script,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
isCloning,
|
isCloning,
|
||||||
}: CloneScriptModalProps) {
|
}: CloneScriptModalProps) => {
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
|
|
||||||
if (!isOpen || !script) return null;
|
if (!isOpen || !script) return null;
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (newName.trim()) {
|
if (newName.trim()) {
|
||||||
onConfirm(newName.trim());
|
onConfirm(newName.trim());
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ interface CloneTaskModalProps {
|
|||||||
isCloning: boolean;
|
isCloning: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CloneTaskModal({
|
export const CloneTaskModal = ({
|
||||||
cronJob,
|
cronJob,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
isCloning,
|
isCloning,
|
||||||
}: CloneTaskModalProps) {
|
}: CloneTaskModalProps) => {
|
||||||
const [newComment, setNewComment] = useState("");
|
const [newComment, setNewComment] = useState("");
|
||||||
|
|
||||||
if (!isOpen || !cronJob) return null;
|
if (!isOpen || !cronJob) return null;
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (newComment.trim()) {
|
if (newComment.trim()) {
|
||||||
onConfirm(newComment.trim());
|
onConfirm(newComment.trim());
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ interface CreateScriptModalProps {
|
|||||||
onFormChange: (updates: Partial<CreateScriptModalProps["form"]>) => void;
|
onFormChange: (updates: Partial<CreateScriptModalProps["form"]>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateScriptModal({
|
export const CreateScriptModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
form,
|
form,
|
||||||
onFormChange,
|
onFormChange,
|
||||||
}: CreateScriptModalProps) {
|
}: CreateScriptModalProps) => {
|
||||||
return (
|
return (
|
||||||
<ScriptModal
|
<ScriptModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "../ui/Button";
|
|||||||
import { Input } from "../ui/Input";
|
import { Input } from "../ui/Input";
|
||||||
import { CronExpressionHelper } from "../CronExpressionHelper";
|
import { CronExpressionHelper } from "../CronExpressionHelper";
|
||||||
import { SelectScriptModal } from "./SelectScriptModal";
|
import { SelectScriptModal } from "./SelectScriptModal";
|
||||||
|
import { UserSwitcher } from "../ui/UserSwitcher";
|
||||||
import { Plus, Terminal, FileText, X } from "lucide-react";
|
import { Plus, Terminal, FileText, X } from "lucide-react";
|
||||||
import { getScriptContent } from "@/app/_server/actions/scripts";
|
import { getScriptContent } from "@/app/_server/actions/scripts";
|
||||||
import { getHostScriptPath } from "@/app/_utils/scripts";
|
import { getHostScriptPath } from "@/app/_utils/scripts";
|
||||||
@@ -21,25 +22,26 @@ interface Script {
|
|||||||
interface CreateTaskModalProps {
|
interface CreateTaskModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (e: React.FormEvent) => void;
|
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
scripts: Script[];
|
scripts: Script[];
|
||||||
form: {
|
form: {
|
||||||
schedule: string;
|
schedule: string;
|
||||||
command: string;
|
command: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
selectedScriptId: string | null;
|
selectedScriptId: string | null;
|
||||||
|
user: string;
|
||||||
};
|
};
|
||||||
onFormChange: (updates: Partial<CreateTaskModalProps["form"]>) => void;
|
onFormChange: (updates: Partial<CreateTaskModalProps["form"]>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateTaskModal({
|
export const CreateTaskModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
scripts,
|
scripts,
|
||||||
form,
|
form,
|
||||||
onFormChange,
|
onFormChange,
|
||||||
}: CreateTaskModalProps) {
|
}: CreateTaskModalProps) => {
|
||||||
const [selectedScriptContent, setSelectedScriptContent] =
|
const [selectedScriptContent, setSelectedScriptContent] =
|
||||||
useState<string>("");
|
useState<string>("");
|
||||||
const [isSelectScriptModalOpen, setIsSelectScriptModalOpen] = useState(false);
|
const [isSelectScriptModalOpen, setIsSelectScriptModalOpen] = useState(false);
|
||||||
@@ -58,10 +60,10 @@ export function CreateTaskModal({
|
|||||||
loadScriptContent();
|
loadScriptContent();
|
||||||
}, [selectedScript]);
|
}, [selectedScript]);
|
||||||
|
|
||||||
const handleScriptSelect = (script: Script) => {
|
const handleScriptSelect = async (script: Script) => {
|
||||||
onFormChange({
|
onFormChange({
|
||||||
selectedScriptId: script.id,
|
selectedScriptId: script.id,
|
||||||
command: getHostScriptPath(script.filename),
|
command: await getHostScriptPath(script.filename),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,6 +90,16 @@ export function CreateTaskModal({
|
|||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Schedule
|
Schedule
|
||||||
@@ -108,11 +120,10 @@ export function CreateTaskModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCustomCommand}
|
onClick={handleCustomCommand}
|
||||||
className={`p-4 rounded-lg border-2 transition-all ${
|
className={`p-4 rounded-lg border-2 transition-all ${!form.selectedScriptId
|
||||||
!form.selectedScriptId
|
|
||||||
? "border-primary bg-primary/5 text-primary"
|
? "border-primary bg-primary/5 text-primary"
|
||||||
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Terminal className="h-5 w-5" />
|
<Terminal className="h-5 w-5" />
|
||||||
@@ -126,11 +137,10 @@ export function CreateTaskModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsSelectScriptModalOpen(true)}
|
onClick={() => setIsSelectScriptModalOpen(true)}
|
||||||
className={`p-4 rounded-lg border-2 transition-all ${
|
className={`p-4 rounded-lg border-2 transition-all ${form.selectedScriptId
|
||||||
form.selectedScriptId
|
|
||||||
? "border-primary bg-primary/5 text-primary"
|
? "border-primary bg-primary/5 text-primary"
|
||||||
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
: "border-border bg-muted/30 text-muted-foreground hover:border-border/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ interface DeleteScriptModalProps {
|
|||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteScriptModal({
|
export const DeleteScriptModal = ({
|
||||||
script,
|
script,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
}: DeleteScriptModalProps) {
|
}: DeleteScriptModalProps) => {
|
||||||
if (!isOpen || !script) return null;
|
if (!isOpen || !script) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ interface DeleteTaskModalProps {
|
|||||||
job: CronJob | null;
|
job: CronJob | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteTaskModal({
|
export const DeleteTaskModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
job,
|
job,
|
||||||
}: DeleteTaskModalProps) {
|
}: DeleteTaskModalProps) => {
|
||||||
if (!job) return null;
|
if (!job) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ interface EditScriptModalProps {
|
|||||||
onFormChange: (updates: Partial<EditScriptModalProps["form"]>) => void;
|
onFormChange: (updates: Partial<EditScriptModalProps["form"]>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditScriptModal({
|
export const EditScriptModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
script,
|
script,
|
||||||
form,
|
form,
|
||||||
onFormChange,
|
onFormChange,
|
||||||
}: EditScriptModalProps) {
|
}: EditScriptModalProps) => {
|
||||||
if (!script) return null;
|
if (!script) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Edit, Terminal } from "lucide-react";
|
|||||||
interface EditTaskModalProps {
|
interface EditTaskModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (e: React.FormEvent) => void;
|
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
form: {
|
form: {
|
||||||
schedule: string;
|
schedule: string;
|
||||||
command: string;
|
command: string;
|
||||||
@@ -18,13 +18,13 @@ interface EditTaskModalProps {
|
|||||||
onFormChange: (updates: Partial<EditTaskModalProps["form"]>) => void;
|
onFormChange: (updates: Partial<EditTaskModalProps["form"]>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditTaskModal({
|
export const EditTaskModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
form,
|
form,
|
||||||
onFormChange,
|
onFormChange,
|
||||||
}: EditTaskModalProps) {
|
}: EditTaskModalProps) => {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button } from "../ui/Button";
|
|||||||
import { Input } from "../ui/Input";
|
import { Input } from "../ui/Input";
|
||||||
import { BashEditor } from "../BashEditor";
|
import { BashEditor } from "../BashEditor";
|
||||||
import { BashSnippetHelper } from "../BashSnippetHelper";
|
import { BashSnippetHelper } from "../BashSnippetHelper";
|
||||||
import { FileText, Code, Plus, Edit } from "lucide-react";
|
import { FileText, Code } from "lucide-react";
|
||||||
import { showToast } from "../ui/Toast";
|
import { showToast } from "../ui/Toast";
|
||||||
|
|
||||||
interface ScriptModalProps {
|
interface ScriptModalProps {
|
||||||
@@ -26,7 +26,7 @@ interface ScriptModalProps {
|
|||||||
additionalFormData?: Record<string, string>;
|
additionalFormData?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScriptModal({
|
export const ScriptModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -36,13 +36,24 @@ export function ScriptModal({
|
|||||||
form,
|
form,
|
||||||
onFormChange,
|
onFormChange,
|
||||||
additionalFormData = {},
|
additionalFormData = {},
|
||||||
}: ScriptModalProps) {
|
}: ScriptModalProps) => {
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
showToast("error", "Validation Error", "Script name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.content.trim()) {
|
||||||
|
showToast("error", "Validation Error", "Script content is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("name", form.name);
|
formData.append("name", form.name.trim());
|
||||||
formData.append("description", form.description);
|
formData.append("description", form.description.trim());
|
||||||
formData.append("content", form.content);
|
formData.append("content", form.content.trim());
|
||||||
|
|
||||||
Object.entries(additionalFormData).forEach(([key, value]) => {
|
Object.entries(additionalFormData).forEach(([key, value]) => {
|
||||||
formData.append(key, value);
|
formData.append(key, value);
|
||||||
@@ -66,18 +77,24 @@ export function ScriptModal({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-foreground mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
Script Name
|
Script Name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => onFormChange({ name: e.target.value })}
|
onChange={(e) => onFormChange({ name: e.target.value })}
|
||||||
placeholder="My Script"
|
placeholder="My Script"
|
||||||
required
|
required
|
||||||
|
className={
|
||||||
|
!form.name.trim()
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-500"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-foreground mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
Description
|
Description{" "}
|
||||||
|
<span className="text-muted-foreground text-xs">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={form.description}
|
value={form.description}
|
||||||
@@ -102,7 +119,7 @@ export function ScriptModal({
|
|||||||
<div className="flex items-center gap-2 mb-4 flex-shrink-0">
|
<div className="flex items-center gap-2 mb-4 flex-shrink-0">
|
||||||
<FileText className="h-4 w-4 text-primary" />
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
<h3 className="text-sm font-medium text-foreground">
|
<h3 className="text-sm font-medium text-foreground">
|
||||||
Script Content
|
Script Content <span className="text-red-500">*</span>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Modal } from "../ui/Modal";
|
import { Modal } from "../ui/Modal";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import { Input } from "../ui/Input";
|
import { Input } from "../ui/Input";
|
||||||
@@ -17,16 +17,28 @@ interface SelectScriptModalProps {
|
|||||||
selectedScriptId: string | null;
|
selectedScriptId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectScriptModal({
|
export const SelectScriptModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
scripts,
|
scripts,
|
||||||
onScriptSelect,
|
onScriptSelect,
|
||||||
selectedScriptId,
|
selectedScriptId,
|
||||||
}: SelectScriptModalProps) {
|
}: SelectScriptModalProps) => {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [previewScript, setPreviewScript] = useState<Script | null>(null);
|
const [previewScript, setPreviewScript] = useState<Script | null>(null);
|
||||||
const [previewContent, setPreviewContent] = useState<string>("");
|
const [previewContent, setPreviewContent] = useState<string>("");
|
||||||
|
const [hostScriptPath, setHostScriptPath] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchHostScriptPath = async () => {
|
||||||
|
const path = await getHostScriptPath(previewScript?.filename || "");
|
||||||
|
setHostScriptPath(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (previewScript) {
|
||||||
|
fetchHostScriptPath();
|
||||||
|
}
|
||||||
|
}, [previewScript]);
|
||||||
|
|
||||||
const filteredScripts = scripts.filter(
|
const filteredScripts = scripts.filter(
|
||||||
(script) =>
|
(script) =>
|
||||||
@@ -97,11 +109,10 @@ export function SelectScriptModal({
|
|||||||
<button
|
<button
|
||||||
key={script.id}
|
key={script.id}
|
||||||
onClick={() => handleScriptClick(script)}
|
onClick={() => handleScriptClick(script)}
|
||||||
className={`w-full p-4 text-left hover:bg-accent/30 transition-colors ${
|
className={`w-full p-4 text-left hover:bg-accent/30 transition-colors ${previewScript?.id === script.id
|
||||||
previewScript?.id === script.id
|
|
||||||
? "bg-primary/5 border-r-2 border-primary"
|
? "bg-primary/5 border-r-2 border-primary"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -156,7 +167,7 @@ export function SelectScriptModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/30 p-3 rounded border border-border/30">
|
<div className="bg-muted/30 p-3 rounded border border-border/30">
|
||||||
<code className="text-sm font-mono text-foreground break-all">
|
<code className="text-sm font-mono text-foreground break-all">
|
||||||
{getHostScriptPath(previewScript.filename)}
|
{hostScriptPath}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -36,9 +36,3 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
Button.displayName = 'Button';
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
export { Button };
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cn } from '@/app/_utils/cn';
|
import { cn } from '@/app/_utils/cn';
|
||||||
import { HTMLAttributes, forwardRef } from 'react';
|
import { HTMLAttributes, forwardRef } from 'react';
|
||||||
|
|
||||||
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -69,4 +69,4 @@ const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|||||||
);
|
);
|
||||||
CardFooter.displayName = 'CardFooter';
|
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> { }
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { }
|
||||||
|
|
||||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
@@ -20,9 +20,3 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
Input.displayName = 'Input';
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
export { Input };
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface MetricCardProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
progressMax?: number;
|
progressMax?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
export const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
className,
|
className,
|
||||||
@@ -99,5 +99,3 @@ const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
MetricCard.displayName = "MetricCard";
|
MetricCard.displayName = "MetricCard";
|
||||||
|
|
||||||
export { MetricCard };
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface ModalProps {
|
|||||||
preventCloseOnClickOutside?: boolean;
|
preventCloseOnClickOutside?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({
|
export const Modal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
@@ -23,7 +23,7 @@ export function Modal({
|
|||||||
size = "md",
|
size = "md",
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
preventCloseOnClickOutside = false,
|
preventCloseOnClickOutside = false,
|
||||||
}: ModalProps) {
|
}: ModalProps) => {
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface PerformanceSummaryProps
|
|||||||
metrics: PerformanceMetric[];
|
metrics: PerformanceMetric[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PerformanceSummary = forwardRef<HTMLDivElement, PerformanceSummaryProps>(
|
export const PerformanceSummary = forwardRef<HTMLDivElement, PerformanceSummaryProps>(
|
||||||
({ className, metrics, ...props }, ref) => {
|
({ className, metrics, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,5 +58,3 @@ const PerformanceSummary = forwardRef<HTMLDivElement, PerformanceSummaryProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
PerformanceSummary.displayName = "PerformanceSummary";
|
PerformanceSummary.displayName = "PerformanceSummary";
|
||||||
|
|
||||||
export { PerformanceSummary };
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface ProgressBarProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
variant?: "default" | "gradient";
|
variant?: "default" | "gradient";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
|
export const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
className,
|
className,
|
||||||
@@ -75,5 +75,3 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
ProgressBar.displayName = "ProgressBar";
|
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,
|
className,
|
||||||
@@ -185,5 +185,3 @@ const Sidebar = forwardRef<HTMLDivElement, SidebarProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
Sidebar.displayName = "Sidebar";
|
Sidebar.displayName = "Sidebar";
|
||||||
|
|
||||||
export { Sidebar };
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface StatusBadgeProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
showText?: boolean;
|
showText?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
|
export const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
className,
|
className,
|
||||||
@@ -105,5 +105,3 @@ const StatusBadge = forwardRef<HTMLDivElement, StatusBadgeProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
StatusBadge.displayName = "StatusBadge";
|
StatusBadge.displayName = "StatusBadge";
|
||||||
|
|
||||||
export { StatusBadge };
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { cn } from "@/app/_utils/cn";
|
import { cn } from "@/app/_utils/cn";
|
||||||
import { HTMLAttributes, forwardRef } from "react";
|
import { HTMLAttributes, forwardRef } from "react";
|
||||||
import { Activity } from "lucide-react";
|
import { Activity } from "lucide-react";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
|
||||||
|
|
||||||
export interface SystemStatusProps extends HTMLAttributes<HTMLDivElement> {
|
export interface SystemStatusProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -10,7 +9,7 @@ export interface SystemStatusProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
isUpdating?: boolean;
|
isUpdating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
|
export const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
|
||||||
(
|
(
|
||||||
{ className, status, details, timestamp, isUpdating = false, ...props },
|
{ className, status, details, timestamp, isUpdating = false, ...props },
|
||||||
ref
|
ref
|
||||||
@@ -80,5 +79,3 @@ const SystemStatus = forwardRef<HTMLDivElement, SystemStatusProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
SystemStatus.displayName = "SystemStatus";
|
SystemStatus.displayName = "SystemStatus";
|
||||||
|
|
||||||
export { SystemStatus };
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
import { type ThemeProviderProps } from 'next-themes/dist/types';
|
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>;
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTheme } from 'next-themes';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export const ThemeToggle = () => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
@@ -28,8 +28,4 @@ export function ThemeToggle() {
|
|||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const toastStyles = {
|
|||||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
|
"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 [isVisible, setIsVisible] = useState(false);
|
||||||
const Icon = toastIcons[toast.type];
|
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 [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
const addToast = (toast: Omit<Toast, "id">) => {
|
const addToast = (toast: Omit<Toast, "id">) => {
|
||||||
@@ -103,12 +103,12 @@ export function ToastContainer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(
|
export const showToast = (
|
||||||
type: Toast["type"],
|
type: Toast["type"],
|
||||||
title: string,
|
title: string,
|
||||||
message?: string,
|
message?: string,
|
||||||
duration?: number
|
duration?: number
|
||||||
) {
|
) => {
|
||||||
if (typeof window !== "undefined" && (window as any).showToast) {
|
if (typeof window !== "undefined" && (window as any).showToast) {
|
||||||
(window as any).showToast({ type, title, message, duration });
|
(window as any).showToast({ type, title, message, duration });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface TruncatedTextProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TruncatedText = forwardRef<HTMLDivElement, TruncatedTextProps>(
|
export const TruncatedText = forwardRef<HTMLDivElement, TruncatedTextProps>(
|
||||||
({ className, text, maxLength = 50, showTooltip = true, ...props }, ref) => {
|
({ className, text, maxLength = 50, showTooltip = true, ...props }, ref) => {
|
||||||
const [showTooltipState, setShowTooltipState] = useState(false);
|
const [showTooltipState, setShowTooltipState] = useState(false);
|
||||||
const shouldTruncate = text.length > maxLength;
|
const shouldTruncate = text.length > maxLength;
|
||||||
@@ -42,5 +42,3 @@ const TruncatedText = forwardRef<HTMLDivElement, TruncatedTextProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
TruncatedText.displayName = "TruncatedText";
|
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,
|
addCronJob,
|
||||||
deleteCronJob,
|
deleteCronJob,
|
||||||
updateCronJob,
|
updateCronJob,
|
||||||
|
pauseCronJob,
|
||||||
|
resumeCronJob,
|
||||||
|
cleanupCrontab,
|
||||||
type CronJob,
|
type CronJob,
|
||||||
} from "@/app/_utils/system";
|
} from "@/app/_utils/system";
|
||||||
|
import { getAllTargetUsers, getUserInfo } from "@/app/_utils/system/hostCrontab";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { getScriptPath } from "@/app/_utils/scripts";
|
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 {
|
try {
|
||||||
return await getCronJobs();
|
return await getCronJobs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -19,16 +27,15 @@ export async function fetchCronJobs(): Promise<CronJob[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createCronJob = async (
|
||||||
|
|
||||||
export async function createCronJob(
|
|
||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
try {
|
try {
|
||||||
const schedule = formData.get("schedule") as string;
|
const schedule = formData.get("schedule") as string;
|
||||||
const command = formData.get("command") as string;
|
const command = formData.get("command") as string;
|
||||||
const comment = formData.get("comment") as string;
|
const comment = formData.get("comment") as string;
|
||||||
const selectedScriptId = formData.get("selectedScriptId") as string;
|
const selectedScriptId = formData.get("selectedScriptId") as string;
|
||||||
|
const user = formData.get("user") as string;
|
||||||
|
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
return { success: false, message: "Schedule is required" };
|
return { success: false, message: "Schedule is required" };
|
||||||
@@ -42,7 +49,7 @@ export async function createCronJob(
|
|||||||
const selectedScript = scripts.find((s) => s.id === selectedScriptId);
|
const selectedScript = scripts.find((s) => s.id === selectedScriptId);
|
||||||
|
|
||||||
if (selectedScript) {
|
if (selectedScript) {
|
||||||
finalCommand = getScriptPath(selectedScript.filename);
|
finalCommand = await getScriptPath(selectedScript.filename);
|
||||||
} else {
|
} else {
|
||||||
return { success: false, message: "Selected script not found" };
|
return { success: false, message: "Selected script not found" };
|
||||||
}
|
}
|
||||||
@@ -53,7 +60,7 @@ export async function createCronJob(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await addCronJob(schedule, finalCommand, comment);
|
const success = await addCronJob(schedule, finalCommand, comment, user);
|
||||||
if (success) {
|
if (success) {
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return { success: true, message: "Cron job created successfully" };
|
return { success: true, message: "Cron job created successfully" };
|
||||||
@@ -66,9 +73,9 @@ export async function createCronJob(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeCronJob(
|
export const removeCronJob = async (
|
||||||
id: string
|
id: string
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
try {
|
try {
|
||||||
const success = await deleteCronJob(id);
|
const success = await deleteCronJob(id);
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -83,9 +90,9 @@ export async function removeCronJob(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editCronJob(
|
export const editCronJob = async (
|
||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
try {
|
try {
|
||||||
const id = formData.get("id") as string;
|
const id = formData.get("id") as string;
|
||||||
const schedule = formData.get("schedule") as string;
|
const schedule = formData.get("schedule") as string;
|
||||||
@@ -109,10 +116,10 @@ export async function editCronJob(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cloneCronJob(
|
export const cloneCronJob = async (
|
||||||
id: string,
|
id: string,
|
||||||
newComment: string
|
newComment: string
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
try {
|
try {
|
||||||
const cronJobs = await getCronJobs();
|
const cronJobs = await getCronJobs();
|
||||||
const originalJob = cronJobs.find((job) => job.id === id);
|
const originalJob = cronJobs.find((job) => job.id === id);
|
||||||
@@ -124,7 +131,8 @@ export async function cloneCronJob(
|
|||||||
const success = await addCronJob(
|
const success = await addCronJob(
|
||||||
originalJob.schedule,
|
originalJob.schedule,
|
||||||
originalJob.command,
|
originalJob.command,
|
||||||
newComment
|
newComment,
|
||||||
|
originalJob.user
|
||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -138,3 +146,112 @@ export async function cloneCronJob(
|
|||||||
return { success: false, message: "Error cloning cron job" };
|
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";
|
export type { Script } from "@/app/_utils/scriptScanner";
|
||||||
|
|
||||||
function sanitizeScriptName(name: string): string {
|
const sanitizeScriptName = (name: string): string => {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9\s-]/g, "")
|
.replace(/[^a-z0-9\s-]/g, "")
|
||||||
@@ -23,7 +23,7 @@ function sanitizeScriptName(name: string): string {
|
|||||||
.substring(0, 50);
|
.substring(0, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateUniqueFilename(baseName: string): Promise<string> {
|
const generateUniqueFilename = async (baseName: string): Promise<string> => {
|
||||||
const scripts = await loadAllScripts();
|
const scripts = await loadAllScripts();
|
||||||
let filename = `${sanitizeScriptName(baseName)}.sh`;
|
let filename = `${sanitizeScriptName(baseName)}.sh`;
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
@@ -36,42 +36,45 @@ async function generateUniqueFilename(baseName: string): Promise<string> {
|
|||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureScriptsDirectory() {
|
const ensureScriptsDirectory = async () => {
|
||||||
if (!existsSync(SCRIPTS_DIR)) {
|
const scriptsDir = await SCRIPTS_DIR();
|
||||||
await mkdir(SCRIPTS_DIR, { recursive: true });
|
if (!existsSync(scriptsDir)) {
|
||||||
|
await mkdir(scriptsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureHostScriptsDirectory() {
|
const ensureHostScriptsDirectory = async () => {
|
||||||
const isDocker = process.env.DOCKER === "true";
|
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
|
||||||
const hostScriptsDir = isDocker
|
|
||||||
? "/app/scripts"
|
const hostScriptsDir = join(hostProjectDir, "scripts");
|
||||||
: join(process.cwd(), "scripts");
|
|
||||||
if (!existsSync(hostScriptsDir)) {
|
if (!existsSync(hostScriptsDir)) {
|
||||||
await mkdir(hostScriptsDir, { recursive: true });
|
await mkdir(hostScriptsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
await ensureScriptsDirectory();
|
||||||
const scriptPath = join(SCRIPTS_DIR, filename);
|
|
||||||
|
const scriptPath = join(scriptsDir, filename);
|
||||||
await writeFile(scriptPath, content, "utf8");
|
await writeFile(scriptPath, content, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteScriptFile(filename: string) {
|
const deleteScriptFile = async (filename: string) => {
|
||||||
const scriptPath = join(SCRIPTS_DIR, filename);
|
const scriptPath = join(await SCRIPTS_DIR(), filename);
|
||||||
if (existsSync(scriptPath)) {
|
if (existsSync(scriptPath)) {
|
||||||
await unlink(scriptPath);
|
await unlink(scriptPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchScripts(): Promise<Script[]> {
|
export const fetchScripts = async (): Promise<Script[]> => {
|
||||||
return await loadAllScripts();
|
return await loadAllScripts();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createScript(
|
export const createScript = async (
|
||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<{ success: boolean; message: string; script?: Script }> {
|
): Promise<{ success: boolean; message: string; script?: Script }> => {
|
||||||
try {
|
try {
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const description = formData.get("description") as string;
|
const description = formData.get("description") as string;
|
||||||
@@ -116,9 +119,9 @@ export async function createScript(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateScript(
|
export const updateScript = async (
|
||||||
formData: FormData
|
formData: FormData
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
try {
|
try {
|
||||||
const id = formData.get("id") as string;
|
const id = formData.get("id") as string;
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
@@ -154,9 +157,9 @@ export async function updateScript(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteScript(
|
export const deleteScript = async (
|
||||||
id: string
|
id: string
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
try {
|
try {
|
||||||
const scripts = await loadAllScripts();
|
const scripts = await loadAllScripts();
|
||||||
const script = scripts.find((s) => s.id === id);
|
const script = scripts.find((s) => s.id === id);
|
||||||
@@ -175,10 +178,10 @@ export async function deleteScript(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cloneScript(
|
export const cloneScript = async (
|
||||||
id: string,
|
id: string,
|
||||||
newName: string
|
newName: string
|
||||||
): Promise<{ success: boolean; message: string; script?: Script }> {
|
): Promise<{ success: boolean; message: string; script?: Script }> => {
|
||||||
try {
|
try {
|
||||||
const scripts = await loadAllScripts();
|
const scripts = await loadAllScripts();
|
||||||
const originalScript = scripts.find((s) => s.id === id);
|
const originalScript = scripts.find((s) => s.id === id);
|
||||||
@@ -224,9 +227,13 @@ export async function cloneScript(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getScriptContent(filename: string): Promise<string> {
|
export const getScriptContent = async (filename: string): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const scriptPath = join(SCRIPTS_DIR, filename);
|
const isDocker = process.env.DOCKER === "true";
|
||||||
|
const scriptPath = isDocker
|
||||||
|
? join("/app/scripts", filename)
|
||||||
|
: join(process.cwd(), "scripts", filename);
|
||||||
|
|
||||||
if (existsSync(scriptPath)) {
|
if (existsSync(scriptPath)) {
|
||||||
const content = await readFile(scriptPath, "utf8");
|
const content = await readFile(scriptPath, "utf8");
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
@@ -253,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;
|
success: boolean;
|
||||||
output: string;
|
output: string;
|
||||||
error: string;
|
error: string;
|
||||||
}> {
|
}> => {
|
||||||
try {
|
try {
|
||||||
await ensureHostScriptsDirectory();
|
await ensureHostScriptsDirectory();
|
||||||
const isDocker = process.env.DOCKER === "true";
|
const isDocker = process.env.DOCKER === "true";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import {
|
import {
|
||||||
loadAllSnippets,
|
loadAllSnippets,
|
||||||
searchBashSnippets,
|
searchBashSnippets,
|
||||||
@@ -11,7 +10,7 @@ import {
|
|||||||
|
|
||||||
export { type BashSnippet } from "@/app/_utils/snippetScanner";
|
export { type BashSnippet } from "@/app/_utils/snippetScanner";
|
||||||
|
|
||||||
export async function fetchSnippets(): Promise<BashSnippet[]> {
|
export const fetchSnippets = async (): Promise<BashSnippet[]> => {
|
||||||
try {
|
try {
|
||||||
return await loadAllSnippets();
|
return await loadAllSnippets();
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
const snippets = await loadAllSnippets();
|
const snippets = await loadAllSnippets();
|
||||||
return searchBashSnippets(snippets, query);
|
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 {
|
try {
|
||||||
const snippets = await loadAllSnippets();
|
const snippets = await loadAllSnippets();
|
||||||
return getSnippetCategories(snippets);
|
return getSnippetCategories(snippets);
|
||||||
@@ -40,9 +39,9 @@ export async function fetchSnippetCategories(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSnippetById(
|
export const fetchSnippetById = async (
|
||||||
id: string
|
id: string
|
||||||
): Promise<BashSnippet | undefined> {
|
): Promise<BashSnippet | undefined> => {
|
||||||
try {
|
try {
|
||||||
const snippets = await loadAllSnippets();
|
const snippets = await loadAllSnippets();
|
||||||
return getSnippetById(snippets, id);
|
return getSnippetById(snippets, id);
|
||||||
@@ -52,9 +51,9 @@ export async function fetchSnippetById(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSnippetsByCategory(
|
export const fetchSnippetsByCategory = async (
|
||||||
category: string
|
category: string
|
||||||
): Promise<BashSnippet[]> {
|
): Promise<BashSnippet[]> => {
|
||||||
try {
|
try {
|
||||||
const snippets = await loadAllSnippets();
|
const snippets = await loadAllSnippets();
|
||||||
return snippets.filter((snippet) => snippet.category === category);
|
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"
|
source: "builtin" | "user"
|
||||||
): Promise<BashSnippet[]> {
|
): Promise<BashSnippet[]> => {
|
||||||
try {
|
try {
|
||||||
const snippets = await loadAllSnippets();
|
const snippets = await loadAllSnippets();
|
||||||
return snippets.filter((snippet) => snippet.source === source);
|
return snippets.filter((snippet) => snippet.source === source);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export const cn = (...inputs: ClassValue[]) => {
|
||||||
return twMerge(clsx(inputs))
|
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;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCronExpression(expression: string): CronExplanation {
|
export const parseCronExpression = (expression: string): CronExplanation => {
|
||||||
try {
|
try {
|
||||||
const cleanExpression = expression.trim();
|
const cleanExpression = expression.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface ScriptMetadata {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMetadata(content: string): ScriptMetadata {
|
const parseMetadata = (content: string): ScriptMetadata => {
|
||||||
const metadata: ScriptMetadata = {};
|
const metadata: ScriptMetadata = {};
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ function parseMetadata(content: string): ScriptMetadata {
|
|||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanScriptsDirectory(dirPath: string): Promise<Script[]> {
|
const scanScriptsDirectory = async (dirPath: string): Promise<Script[]> => {
|
||||||
const scripts: Script[] = [];
|
const scripts: Script[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -46,13 +46,13 @@ async function scanScriptsDirectory(dirPath: string): Promise<Script[]> {
|
|||||||
const content = await fs.readFile(filePath, "utf-8");
|
const content = await fs.readFile(filePath, "utf-8");
|
||||||
const metadata = parseMetadata(content);
|
const metadata = parseMetadata(content);
|
||||||
|
|
||||||
if (metadata.id && metadata.title && metadata.description) {
|
if (metadata.id && metadata.title) {
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
scripts.push({
|
scripts.push({
|
||||||
id: metadata.id,
|
id: metadata.id,
|
||||||
name: metadata.title,
|
name: metadata.title,
|
||||||
description: metadata.description,
|
description: metadata.description || "",
|
||||||
filename: file,
|
filename: file,
|
||||||
createdAt: stats.birthtime.toISOString(),
|
createdAt: stats.birthtime.toISOString(),
|
||||||
});
|
});
|
||||||
@@ -66,7 +66,7 @@ async function scanScriptsDirectory(dirPath: string): Promise<Script[]> {
|
|||||||
return scripts;
|
return scripts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAllScripts(): Promise<Script[]> {
|
export const loadAllScripts = async (): Promise<Script[]> => {
|
||||||
const isDocker = process.env.DOCKER === "true";
|
const isDocker = process.env.DOCKER === "true";
|
||||||
const scriptsDir = isDocker
|
const scriptsDir = isDocker
|
||||||
? "/app/scripts"
|
? "/app/scripts"
|
||||||
@@ -74,7 +74,7 @@ export async function loadAllScripts(): Promise<Script[]> {
|
|||||||
return await scanScriptsDirectory(scriptsDir);
|
return await scanScriptsDirectory(scriptsDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchScripts(scripts: Script[], query: string): Script[] {
|
export const searchScripts = (scripts: Script[], query: string): Script[] => {
|
||||||
const lowercaseQuery = query.toLowerCase();
|
const lowercaseQuery = query.toLowerCase();
|
||||||
return scripts.filter(
|
return scripts.filter(
|
||||||
(script) =>
|
(script) =>
|
||||||
@@ -83,9 +83,9 @@ export function searchScripts(scripts: Script[], query: string): Script[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScriptById(
|
export const getScriptById = (
|
||||||
scripts: Script[],
|
scripts: Script[],
|
||||||
id: string
|
id: string
|
||||||
): Script | undefined {
|
): Script | undefined => {
|
||||||
return scripts.find((script) => script.id === id);
|
return scripts.find((script) => script.id === id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
const isDocker = process.env.DOCKER === "true";
|
const isDocker = process.env.DOCKER === "true";
|
||||||
const SCRIPTS_DIR = isDocker ? "/app/scripts" : join(process.cwd(), "scripts");
|
const SCRIPTS_DIR = async () => {
|
||||||
|
if (isDocker && process.env.HOST_PROJECT_DIR) {
|
||||||
|
return `${process.env.HOST_PROJECT_DIR}/scripts`;
|
||||||
|
}
|
||||||
|
return join(process.cwd(), "scripts");
|
||||||
|
};
|
||||||
|
|
||||||
export function getScriptPath(filename: string): string {
|
export const getScriptPath = async (filename: string): Promise<string> => {
|
||||||
return join(SCRIPTS_DIR, filename);
|
return join(await SCRIPTS_DIR(), filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHostScriptPath(filename: string): string {
|
export const getHostScriptPath = async (filename: string): Promise<string> => {
|
||||||
const hostProjectDir =
|
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
|
||||||
process.env.NEXT_PUBLIC_HOST_PROJECT_DIR || process.cwd();
|
|
||||||
const hostScriptsDir = join(hostProjectDir, "scripts");
|
const hostScriptsDir = join(hostProjectDir, "scripts");
|
||||||
return `bash ${join(hostScriptsDir, filename)}`;
|
return `bash ${join(hostScriptsDir, filename)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface SnippetMetadata {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMetadata(content: string): SnippetMetadata {
|
const parseMetadata = (content: string): SnippetMetadata => {
|
||||||
const metadata: SnippetMetadata = {};
|
const metadata: SnippetMetadata = {};
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ function parseMetadata(content: string): SnippetMetadata {
|
|||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTemplate(content: string): string {
|
const extractTemplate = (content: string): string => {
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
const templateLines: string[] = [];
|
const templateLines: string[] = [];
|
||||||
let inTemplate = false;
|
let inTemplate = false;
|
||||||
@@ -75,10 +75,10 @@ function extractTemplate(content: string): string {
|
|||||||
return templateLines.join("\n").trim();
|
return templateLines.join("\n").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanSnippetDirectory(
|
const scanSnippetDirectory = async (
|
||||||
dirPath: string,
|
dirPath: string,
|
||||||
source: "builtin" | "user"
|
source: "builtin" | "user"
|
||||||
): Promise<BashSnippet[]> {
|
): Promise<BashSnippet[]> => {
|
||||||
const snippets: BashSnippet[] = [];
|
const snippets: BashSnippet[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -117,7 +117,7 @@ async function scanSnippetDirectory(
|
|||||||
return snippets;
|
return snippets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAllSnippets(): Promise<BashSnippet[]> {
|
export const loadAllSnippets = async (): Promise<BashSnippet[]> => {
|
||||||
const isDocker = process.env.DOCKER === "true";
|
const isDocker = process.env.DOCKER === "true";
|
||||||
|
|
||||||
let builtinSnippetsPath: string;
|
let builtinSnippetsPath: string;
|
||||||
@@ -141,10 +141,10 @@ export async function loadAllSnippets(): Promise<BashSnippet[]> {
|
|||||||
return [...builtinSnippets, ...userSnippets];
|
return [...builtinSnippets, ...userSnippets];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchBashSnippets(
|
export const searchBashSnippets = (
|
||||||
snippets: BashSnippet[],
|
snippets: BashSnippet[],
|
||||||
query: string
|
query: string
|
||||||
): BashSnippet[] {
|
): BashSnippet[] => {
|
||||||
const lowercaseQuery = query.toLowerCase();
|
const lowercaseQuery = query.toLowerCase();
|
||||||
return snippets.filter(
|
return snippets.filter(
|
||||||
(snippet) =>
|
(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));
|
const categories = new Set(snippets.map((snippet) => snippet.category));
|
||||||
return Array.from(categories).sort();
|
return Array.from(categories).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSnippetById(
|
export const getSnippetById = (
|
||||||
snippets: BashSnippet[],
|
snippets: BashSnippet[],
|
||||||
id: string
|
id: string
|
||||||
): BashSnippet | undefined {
|
): BashSnippet | undefined => {
|
||||||
return snippets.find((snippet) => snippet.id === id);
|
return snippets.find((snippet) => snippet.id === id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,8 @@ export {
|
|||||||
addCronJob,
|
addCronJob,
|
||||||
deleteCronJob,
|
deleteCronJob,
|
||||||
updateCronJob,
|
updateCronJob,
|
||||||
type CronJob
|
pauseCronJob,
|
||||||
|
resumeCronJob,
|
||||||
|
cleanupCrontab,
|
||||||
|
type CronJob,
|
||||||
} from "./system/cron";
|
} from "./system/cron";
|
||||||
|
|||||||
@@ -1,229 +1,228 @@
|
|||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
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);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
export interface CronJob {
|
export interface CronJob {
|
||||||
id: string;
|
id: string;
|
||||||
schedule: string;
|
schedule: string;
|
||||||
command: string;
|
command: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
user: string;
|
||||||
|
paused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readCronFiles(): Promise<string> {
|
const isDocker = (): boolean => process.env.DOCKER === "true";
|
||||||
const isDocker = process.env.DOCKER === "true";
|
|
||||||
|
|
||||||
if (!isDocker) {
|
const readUserCrontab = async (user: string): Promise<string> => {
|
||||||
try {
|
if (isDocker()) {
|
||||||
const { stdout } = await execAsync('crontab -l 2>/dev/null || echo ""');
|
const userCrontabs = await readAllHostCrontabs();
|
||||||
return stdout;
|
const targetUserCrontab = userCrontabs.find((uc) => uc.user === user);
|
||||||
} catch (error) {
|
return targetUserCrontab?.content || "";
|
||||||
console.error("Error reading crontab:", error);
|
} else {
|
||||||
return "";
|
const { stdout } = await execAsync(
|
||||||
}
|
`crontab -l -u ${user} 2>/dev/null || echo ""`
|
||||||
}
|
);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return await readHostCrontab();
|
const writeUserCrontab = async (user: string, content: string): Promise<boolean> => {
|
||||||
}
|
if (isDocker()) {
|
||||||
|
return await writeHostCrontabForUser(user, content);
|
||||||
async function writeCronFiles(content: string): Promise<boolean> {
|
} else {
|
||||||
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[]> {
|
|
||||||
try {
|
try {
|
||||||
const cronContent = await readCronFiles();
|
await execAsync(`echo '${content}' | crontab -u ${user} -`);
|
||||||
|
return true;
|
||||||
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;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error getting cron jobs:", error);
|
console.error(`Error writing crontab for user ${user}:`, error);
|
||||||
return [];
|
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(
|
export const addCronJob = async (
|
||||||
schedule: string,
|
schedule: string,
|
||||||
command: string,
|
command: string,
|
||||||
comment: string = ""
|
comment: string = "",
|
||||||
): Promise<boolean> {
|
user?: string
|
||||||
try {
|
): Promise<boolean> => {
|
||||||
const cronContent = await readCronFiles();
|
try {
|
||||||
|
if (user) {
|
||||||
|
const cronContent = await readUserCrontab(user);
|
||||||
|
const newEntry = comment
|
||||||
|
? `# ${comment}\n${schedule} ${command}`
|
||||||
|
: `${schedule} ${command}`;
|
||||||
|
|
||||||
const newEntry = comment
|
let newCron;
|
||||||
? `# ${comment}\n${schedule} ${command}`
|
if (cronContent.trim() === "") {
|
||||||
: `${schedule} ${command}`;
|
newCron = newEntry;
|
||||||
|
} else {
|
||||||
|
const existingContent = cronContent.trim();
|
||||||
|
newCron = await cleanCrontabContent(existingContent + "\n" + newEntry);
|
||||||
|
}
|
||||||
|
|
||||||
let newCron;
|
return await writeUserCrontab(user, newCron);
|
||||||
if (cronContent.trim() === "") {
|
} else {
|
||||||
newCron = newEntry;
|
const cronContent = await readCronFiles();
|
||||||
} else {
|
|
||||||
const existingContent = cronContent.endsWith('\n') ? cronContent : cronContent + '\n';
|
|
||||||
newCron = existingContent + newEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await writeCronFiles(newCron);
|
const newEntry = comment
|
||||||
} catch (error) {
|
? `# ${comment}\n${schedule} ${command}`
|
||||||
console.error("Error adding cron job:", error);
|
: `${schedule} ${command}`;
|
||||||
return false;
|
|
||||||
|
let newCron;
|
||||||
|
if (cronContent.trim() === "") {
|
||||||
|
newCron = newEntry;
|
||||||
|
} else {
|
||||||
|
const existingContent = cronContent.trim();
|
||||||
|
newCron = await cleanCrontabContent(existingContent + "\n" + newEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await writeCronFiles(newCron);
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error("Error adding cron job:", error);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCronJob(
|
export const deleteCronJob = async (id: string): Promise<boolean> => {
|
||||||
id: string,
|
try {
|
||||||
schedule: string,
|
const [user, jobIndexStr] = id.split("-");
|
||||||
command: string,
|
const jobIndex = parseInt(jobIndexStr);
|
||||||
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-", ""));
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
const cronContent = await readUserCrontab(user);
|
||||||
const line = lines[i];
|
const lines = cronContent.split("\n");
|
||||||
const trimmedLine = line.trim();
|
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")) {
|
export const updateCronJob = async (
|
||||||
cronEntries.push(trimmedLine);
|
id: string,
|
||||||
} else if (trimmedLine.startsWith("#")) {
|
schedule: string,
|
||||||
if (i + 1 < lines.length && !lines[i + 1].trim().startsWith("#") && lines[i + 1].trim()) {
|
command: string,
|
||||||
currentComment = trimmedLine;
|
comment: string = ""
|
||||||
} else {
|
): Promise<boolean> => {
|
||||||
cronEntries.push(trimmedLine);
|
try {
|
||||||
}
|
const [user, jobIndexStr] = id.split("-");
|
||||||
} else {
|
const jobIndex = parseInt(jobIndexStr);
|
||||||
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 = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newCron = cronEntries.join("\n") + "\n";
|
const cronContent = await readUserCrontab(user);
|
||||||
await writeCronFiles(newCron);
|
const lines = cronContent.split("\n");
|
||||||
return true;
|
const newCronEntries = updateJobInLines(lines, jobIndex, schedule, command, comment);
|
||||||
} catch (error) {
|
const newCron = await cleanCrontabContent(newCronEntries.join("\n"));
|
||||||
console.error("Error updating cron job:", error);
|
|
||||||
|
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;
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,81 +3,209 @@ import { promisify } from "util";
|
|||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
async function execHostCrontab(command: string): Promise<string> {
|
export interface UserInfo {
|
||||||
try {
|
username: string;
|
||||||
const { stdout } = await execAsync(
|
uid: number;
|
||||||
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`
|
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}"`
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error executing host crontab command:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTargetUser = async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
if (process.env.HOST_CRONTAB_USER) {
|
||||||
|
return process.env.HOST_CRONTAB_USER;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await execAsync('stat -c "%U" /var/run/docker.sock');
|
||||||
|
const dockerSocketOwner = stdout.trim();
|
||||||
|
|
||||||
|
if (dockerSocketOwner === "root") {
|
||||||
|
try {
|
||||||
|
const projectDir = process.env.HOST_PROJECT_DIR;
|
||||||
|
|
||||||
|
if (projectDir) {
|
||||||
|
const dirOwner = await execHostCrontab(
|
||||||
|
`stat -c "%U" "${projectDir}"`
|
||||||
|
);
|
||||||
|
return dirOwner.trim();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Could not detect user from project directory:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const users = await execHostCrontab(
|
||||||
|
'getent passwd | grep ":/home/" | head -1 | cut -d: -f1'
|
||||||
);
|
);
|
||||||
return stdout;
|
const firstUser = users.trim();
|
||||||
} catch (error: any) {
|
if (firstUser) {
|
||||||
console.error("Error executing host crontab command:", error);
|
return firstUser;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTargetUser(): Promise<string> {
|
|
||||||
try {
|
|
||||||
if (process.env.HOST_CRONTAB_USER) {
|
|
||||||
return process.env.HOST_CRONTAB_USER;
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Could not detect user from passwd:", error);
|
||||||
|
}
|
||||||
|
|
||||||
const { stdout } = await execAsync('stat -c "%U" /var/run/docker.sock');
|
return "root";
|
||||||
const dockerSocketOwner = stdout.trim();
|
|
||||||
|
|
||||||
if (dockerSocketOwner === 'root') {
|
|
||||||
try {
|
|
||||||
const projectDir = process.env.NEXT_PUBLIC_HOST_PROJECT_DIR;
|
|
||||||
if (projectDir) {
|
|
||||||
const dirOwner = await execHostCrontab(`stat -c "%U" "${projectDir}"`);
|
|
||||||
return dirOwner.trim();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Could not detect user from project directory:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const users = await execHostCrontab('getent passwd | grep ":/home/" | head -1 | cut -d: -f1');
|
|
||||||
const firstUser = users.trim();
|
|
||||||
if (firstUser) {
|
|
||||||
return firstUser;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Could not detect user from passwd:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'root';
|
|
||||||
}
|
|
||||||
|
|
||||||
return dockerSocketOwner;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error detecting target user:", error);
|
|
||||||
return 'root';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return dockerSocketOwner;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error detecting target user:", error);
|
||||||
|
return "root";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readHostCrontab(): Promise<string> {
|
export const getAllTargetUsers = async (): Promise<string[]> => {
|
||||||
try {
|
try {
|
||||||
const user = await getTargetUser();
|
if (process.env.HOST_CRONTAB_USER) {
|
||||||
return await execHostCrontab(`crontab -l -u ${user} 2>/dev/null || echo ""`);
|
return process.env.HOST_CRONTAB_USER.split(",").map((u) => u.trim());
|
||||||
} catch (error) {
|
|
||||||
console.error("Error reading host crontab:", error);
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 async function writeHostCrontab(content: string): Promise<boolean> {
|
export const readHostCrontab = async (): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const user = await getTargetUser();
|
const user = await getTargetUser();
|
||||||
let finalContent = content;
|
return await execHostCrontab(
|
||||||
if (!finalContent.endsWith('\n')) {
|
`crontab -l -u ${user} 2>/dev/null || echo ""`
|
||||||
finalContent += '\n';
|
);
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error("Error reading host crontab:", error);
|
||||||
const base64Content = Buffer.from(finalContent).toString('base64');
|
return "";
|
||||||
await execHostCrontab(`echo '${base64Content}' | base64 -d | crontab -u ${user} -`);
|
}
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error writing host crontab:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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:", error);
|
||||||
|
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 />
|
<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 />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
services:
|
services:
|
||||||
cronjob-manager:
|
cronjob-manager:
|
||||||
image: ghcr.io/fccview/cronmaster:main
|
image: ghcr.io/fccview/cronmaster:1.3.0
|
||||||
container_name: cronmaster-test
|
container_name: cronmaster
|
||||||
user: "root"
|
user: "root"
|
||||||
ports:
|
ports:
|
||||||
# Feel free to change port, 3000 is very common so I like to map it to something else
|
# Feel free to change port, 3000 is very common so I like to map it to something else
|
||||||
- "40124:3000"
|
- "40123:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DOCKER=true
|
- DOCKER=true
|
||||||
|
# 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
|
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
|
||||||
- NEXT_PUBLIC_HOST_PROJECT_DIR=/path/to/cronmaster/directory
|
|
||||||
# If docker struggles to find your crontab user, update this variable with it.
|
# 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/
|
# 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
|
# - HOST_CRONTAB_USER=fccview
|
||||||
volumes:
|
volumes:
|
||||||
# Mount Docker socket to execute commands on host
|
# Mount Docker socket to execute commands on host
|
||||||
@@ -20,7 +22,7 @@ services:
|
|||||||
|
|
||||||
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
|
# 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
|
# 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 NEXT_PUBLIC_HOST_PROJECT_DIR variable set above)
|
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
|
||||||
- ./scripts:/app/scripts
|
- ./scripts:/app/scripts
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./snippets:/app/snippets
|
- ./snippets:/app/snippets
|
||||||
@@ -33,4 +35,4 @@ services:
|
|||||||
init: true
|
init: true
|
||||||
|
|
||||||
# Default platform is set to amd64, uncomment to use arm64.
|
# Default platform is set to amd64, uncomment to use arm64.
|
||||||
#platform: linux/arm64
|
#platform: linux/arm64
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user