(etke.cc-exclusive) Server Actions page (#457)
* WIP: add scheduler commands * refactor scheduler commands page * WIP on CRUD for ScheduledCommands * more refactoring, finish CRUD On scheduled pages * Add info text about Scheduler service * Finish recurring commands add/edit * add more texts * fix server status behavior on not-loaded-yet state; adjust texts; lint fixes * add link to the help pages in the commands palette * Move Commands Panel to ServerSchedulesPage * Rename Server Schedules to Server Actions * more texts, a bit changed visual of the actions page, lint fix * add docs * fix tests * Add UTC label to scheduled command create/edit
This commit is contained in:
committed by
GitHub
parent
e2d3c0792b
commit
0832c43d76
37
src/components/etke.cc/CurrentlyRunningCommand.tsx
Normal file
37
src/components/etke.cc/CurrentlyRunningCommand.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Stack, Tooltip, Typography, Box, Link } from "@mui/material";
|
||||
import { useStore } from "react-admin";
|
||||
|
||||
import { ServerProcessResponse } from "../../synapse/dataProvider";
|
||||
import { getTimeSince } from "../../utils/date";
|
||||
|
||||
const CurrentlyRunningCommand = () => {
|
||||
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", {
|
||||
command: "",
|
||||
locked_at: "",
|
||||
});
|
||||
const { command, locked_at } = serverProcess;
|
||||
|
||||
if (!command || !locked_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={1} direction="row" alignItems="center">
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="h5">Currently running:</Typography>
|
||||
<Typography variant="h5" color="text.secondary">
|
||||
<Link href={"https://etke.cc/help/extras/scheduler/#" + command} target="_blank">
|
||||
{command}
|
||||
</Link>
|
||||
<Tooltip title={locked_at.toString()}>
|
||||
<Typography component="span" color="text.secondary" sx={{ display: "inline-block", ml: 1 }}>
|
||||
(started {getTimeSince(locked_at)} ago)
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentlyRunningCommand;
|
||||
@@ -47,10 +47,21 @@ In the application bar the new notifications icon is displayed that shows the nu
|
||||
|
||||
When you click on a notification from the [Server Notifications icon](#server-notifications-icon)'s list in the application bar, you will be redirected to the Server Notifications page. This page contains the full text of all the notifications you have about your server.
|
||||
|
||||
### Server Actions Page
|
||||
|
||||

|
||||
|
||||
When you click on the `Server Actions` sidebar menu item, you will be redirected to the Server Actions page.
|
||||
On this page you can do the following:
|
||||
|
||||
* [Run a command](#server-commands-panel) on your server immediately
|
||||
* [Schedule a command](https://etke.cc/help/extras/scheduler/#schedule) to run at a specific date and time
|
||||
* [Configure a recurring schedule](https://etke.cc/help/extras/scheduler/#recurring) for a command to run at a specific time every week
|
||||
|
||||
### Server Commands Panel
|
||||
|
||||

|
||||
|
||||
When you open [Server Status page](#server-status-page), you will see the Server Commands panel. This panel contains all
|
||||
[the commands](https://etke.cc/help/extras/scheduler/#commands) you can run on your server in 1 click. Once command is finished, you will get a notification about the
|
||||
result.
|
||||
When you open [Server Actions page](#server-status-page), you will see the Server Commands panel.
|
||||
This panel contains all [the commands](https://etke.cc/help/extras/scheduler/#commands) you can run on your server in 1 click.
|
||||
Once command is finished, you will get a notification about the result.
|
||||
|
||||
51
src/components/etke.cc/ServerActionsPage.tsx
Normal file
51
src/components/etke.cc/ServerActionsPage.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import RestoreIcon from "@mui/icons-material/Restore";
|
||||
import ScheduleIcon from "@mui/icons-material/Schedule";
|
||||
import { Box, Typography, Link, Divider } from "@mui/material";
|
||||
import { Stack } from "@mui/material";
|
||||
|
||||
import CurrentlyRunningCommand from "./CurrentlyRunningCommand";
|
||||
import ServerCommandsPanel from "./ServerCommandsPanel";
|
||||
import RecurringCommandsList from "./schedules/components/recurring/RecurringCommandsList";
|
||||
import ScheduledCommandsList from "./schedules/components/scheduled/ScheduledCommandsList";
|
||||
const ServerActionsPage = () => {
|
||||
return (
|
||||
<Stack spacing={3} mt={3}>
|
||||
<Stack direction="column">
|
||||
<CurrentlyRunningCommand />
|
||||
<ServerCommandsPanel />
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="h5">
|
||||
<ScheduleIcon sx={{ verticalAlign: "middle", mr: 1 }} /> Scheduled commands
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
The following commands are scheduled to run at specific times. You can view their details and modify them as
|
||||
needed. More details about the mode can be found{" "}
|
||||
<Link href="https://etke.cc/help/extras/scheduler/#schedule" target="_blank">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
<ScheduledCommandsList />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="h5">
|
||||
<RestoreIcon sx={{ verticalAlign: "middle", mr: 1 }} /> Recurring commands
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
The following commands are set to run at specific weekday and time (weekly). You can view their details and
|
||||
modify them as needed. More details about the mode can be found{" "}
|
||||
<Link href="https://etke.cc/help/extras/scheduler/#recurring" target="_blank">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
<RecurringCommandsList />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerActionsPage;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlayArrow, CheckCircle } from "@mui/icons-material";
|
||||
import { PlayArrow, CheckCircle, HelpCenter, Construction } from "@mui/icons-material";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -10,12 +10,15 @@ import {
|
||||
Alert,
|
||||
TextField,
|
||||
Box,
|
||||
Link,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Loading, useDataProvider, useCreatePath, useStore } from "react-admin";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { useAppContext } from "../../Context";
|
||||
import { useServerCommands } from "./hooks/useServerCommands";
|
||||
import { ServerCommand, ServerProcessResponse } from "../../synapse/dataProvider";
|
||||
import { Icons } from "../../utils/icons";
|
||||
|
||||
@@ -26,34 +29,20 @@ const renderIcon = (icon: string) => {
|
||||
|
||||
const ServerCommandsPanel = () => {
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const createPath = useCreatePath();
|
||||
if (!etkeccAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [serverCommands, setServerCommands] = useState<Record<string, ServerCommand>>({});
|
||||
const createPath = useCreatePath();
|
||||
const { isLoading, serverCommands, setServerCommands } = useServerCommands();
|
||||
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", {
|
||||
command: "",
|
||||
locked_at: "",
|
||||
});
|
||||
const [commandIsRunning, setCommandIsRunning] = useState<boolean>(serverProcess.command !== "");
|
||||
const [commandResult, setCommandResult] = useState<any[]>([]);
|
||||
|
||||
const [commandResult, setCommandResult] = useState<React.ReactNode[]>([]);
|
||||
const dataProvider = useDataProvider();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchIsAdmin = async () => {
|
||||
const serverCommandsResponse = await dataProvider.getServerCommands(etkeccAdmin);
|
||||
if (serverCommandsResponse) {
|
||||
const serverCommands = serverCommandsResponse;
|
||||
Object.keys(serverCommandsResponse).forEach((command: string) => {
|
||||
serverCommands[command].additionalArgs = "";
|
||||
});
|
||||
setServerCommands(serverCommands);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchIsAdmin();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverProcess.command === "") {
|
||||
setCommandIsRunning(false);
|
||||
@@ -103,11 +92,12 @@ const ServerCommandsPanel = () => {
|
||||
commandScheduledText += `, with additional args: ${additionalArgs}`;
|
||||
}
|
||||
|
||||
results.push(<Box>{commandScheduledText}</Box>);
|
||||
results.push(<Box key="command-text">{commandScheduledText}</Box>);
|
||||
results.push(
|
||||
<Box>
|
||||
<Box key="notification-link">
|
||||
Expect your result in the{" "}
|
||||
<Link to={createPath({ resource: "server_notifications", type: "list" })}>Notifications</Link> page soon.
|
||||
<RouterLink to={createPath({ resource: "server_notifications", type: "list" })}>Notifications</RouterLink> page
|
||||
soon.
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -138,25 +128,40 @@ const ServerCommandsPanel = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Server Commands</h2>
|
||||
<TableContainer component={Paper}>
|
||||
<Typography variant="h5">
|
||||
<Construction sx={{ verticalAlign: "middle", mr: 1 }} /> Available Commands
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 0 }}>
|
||||
The following commands are available to run. More details about each of them can be found{" "}
|
||||
<Link href="https://etke.cc/help/extras/scheduler/#commands" target="_blank">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
<TableContainer component={Paper} sx={{ mt: 2 }}>
|
||||
<Table sx={{ minWidth: 450 }} size="small" aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Command</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(serverCommands).map(([command, { icon, args, description, additionalArgs }]) => (
|
||||
<TableRow key={command} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<TableRow key={command}>
|
||||
<TableCell scope="row">
|
||||
<Box>
|
||||
{renderIcon(icon)}
|
||||
{command}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link href={"https://etke.cc/help/extras/scheduler/#" + command} target="_blank">
|
||||
<Button size="small" startIcon={<HelpCenter />} title={command + " help"} />
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{description}</TableCell>
|
||||
<TableCell>
|
||||
{args && (
|
||||
|
||||
@@ -157,16 +157,26 @@ export const ServerStatusStyledBadge = ({
|
||||
command,
|
||||
locked_at,
|
||||
isOkay,
|
||||
isLoaded,
|
||||
inSidebar = false,
|
||||
}: {
|
||||
command: string;
|
||||
locked_at: string;
|
||||
isOkay: boolean;
|
||||
isLoaded: boolean;
|
||||
inSidebar: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
let badgeBackgroundColor = isOkay ? theme.palette.success.light : theme.palette.error.main;
|
||||
let badgeColor = isOkay ? theme.palette.success.light : theme.palette.error.main;
|
||||
let badgeBackgroundColor = isLoaded
|
||||
? isOkay
|
||||
? theme.palette.success.light
|
||||
: theme.palette.error.main
|
||||
: theme.palette.grey[600];
|
||||
let badgeColor = isLoaded
|
||||
? isOkay
|
||||
? theme.palette.success.light
|
||||
: theme.palette.error.main
|
||||
: theme.palette.grey[600];
|
||||
|
||||
if (command && locked_at) {
|
||||
badgeBackgroundColor = theme.palette.warning.main;
|
||||
@@ -220,6 +230,7 @@ const ServerStatusBadge = () => {
|
||||
command={command || ""}
|
||||
locked_at={locked_at || ""}
|
||||
isOkay={isOkay}
|
||||
isLoaded={successCheck}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import EngineeringIcon from "@mui/icons-material/Engineering";
|
||||
import { Box, Stack, Typography, Paper, Link, Chip, Divider, Tooltip, ChipProps } from "@mui/material";
|
||||
import { Alert, Box, Stack, Typography, Paper, Link, Chip, Divider, Tooltip, ChipProps } from "@mui/material";
|
||||
import { useStore } from "ra-core";
|
||||
|
||||
import ServerCommandsPanel from "./ServerCommandsPanel";
|
||||
import CurrentlyRunningCommand from "./CurrentlyRunningCommand";
|
||||
import { ServerProcessResponse, ServerStatusComponent, ServerStatusResponse } from "../../synapse/dataProvider";
|
||||
import { getTimeSince } from "../../utils/date";
|
||||
|
||||
@@ -68,8 +68,7 @@ const ServerStatusPage = () => {
|
||||
return (
|
||||
<Paper elevation={3} sx={{ p: 3, mt: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<CloseIcon color="error" />
|
||||
<Typography color="error">Unable to fetch server status. Please try again later.</Typography>
|
||||
<Typography color="info">Fetching real-time server health... Just a moment!</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
@@ -86,25 +85,23 @@ const ServerStatusPage = () => {
|
||||
{host}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{command && locked_at && (
|
||||
<Stack spacing={1} direction="row" alignItems="center">
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="h5">Currently running:</Typography>
|
||||
<Typography variant="h5" color="text.secondary">
|
||||
<Link href={"https://etke.cc/help/extras/scheduler/#" + command} target="_blank">
|
||||
{command}
|
||||
</Link>
|
||||
<Tooltip title={locked_at.toString()}>
|
||||
<Typography component="span" color="text.secondary" sx={{ display: "inline-block", ml: 1 }}>
|
||||
(started {getTimeSince(locked_at)} ago)
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<ServerCommandsPanel />
|
||||
<CurrentlyRunningCommand />
|
||||
|
||||
<Typography variant="body1">
|
||||
This is a{" "}
|
||||
<Link href="https://etke.cc/services/monitoring/" target="_blank">
|
||||
monitoring report
|
||||
</Link>{" "}
|
||||
of the server. If any of the checks below concern you, please check the{" "}
|
||||
<Link
|
||||
href="https://etke.cc/services/monitoring/#what-to-do-if-the-monitoring-report-shows-issues"
|
||||
target="_blank"
|
||||
>
|
||||
suggested actions
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} direction="row">
|
||||
{Object.keys(groupedResults).map((category, idx) => (
|
||||
|
||||
29
src/components/etke.cc/hooks/useServerCommands.ts
Normal file
29
src/components/etke.cc/hooks/useServerCommands.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useDataProvider } from "react-admin";
|
||||
|
||||
import { useAppContext } from "../../../Context";
|
||||
import { ServerCommand } from "../../../synapse/dataProvider";
|
||||
|
||||
export const useServerCommands = () => {
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [serverCommands, setServerCommands] = useState<Record<string, ServerCommand>>({});
|
||||
const dataProvider = useDataProvider();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchServerCommands = async () => {
|
||||
const serverCommandsResponse = await dataProvider.getServerCommands(etkeccAdmin);
|
||||
if (serverCommandsResponse) {
|
||||
const serverCommands = serverCommandsResponse;
|
||||
Object.keys(serverCommandsResponse).forEach((command: string) => {
|
||||
serverCommands[command].additionalArgs = "";
|
||||
});
|
||||
setServerCommands(serverCommands);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchServerCommands();
|
||||
}, [dataProvider, etkeccAdmin]);
|
||||
|
||||
return { isLoading, serverCommands, setServerCommands };
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
const transformCommandsToChoices = (commands: Record<string, any>) => {
|
||||
return Object.entries(commands).map(([key, value]) => ({
|
||||
id: key,
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
}));
|
||||
};
|
||||
|
||||
const ScheduledCommandCreate = () => {
|
||||
const commandChoices = transformCommandsToChoices(serverCommands);
|
||||
|
||||
return (
|
||||
<SimpleForm>
|
||||
<SelectInput
|
||||
source="command"
|
||||
choices={commandChoices}
|
||||
optionText={choice => `${choice.name} - ${choice.description}`}
|
||||
/>
|
||||
</SimpleForm>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import { Card, CardContent, CardHeader, Box, Alert, Typography, Link } from "@mui/material";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Form,
|
||||
TextInput,
|
||||
SaveButton,
|
||||
useNotify,
|
||||
useDataProvider,
|
||||
Loading,
|
||||
Button,
|
||||
SelectInput,
|
||||
TimeInput,
|
||||
} from "react-admin";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
|
||||
import RecurringDeleteButton from "./RecurringDeleteButton";
|
||||
import { useAppContext } from "../../../../../Context";
|
||||
import { RecurringCommand } from "../../../../../synapse/dataProvider";
|
||||
import { useServerCommands } from "../../../hooks/useServerCommands";
|
||||
import { useRecurringCommands } from "../../hooks/useRecurringCommands";
|
||||
|
||||
const transformCommandsToChoices = (commands: Record<string, any>) => {
|
||||
return Object.entries(commands).map(([key, value]) => ({
|
||||
id: key,
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
}));
|
||||
};
|
||||
|
||||
const ArgumentsField = ({ serverCommands }) => {
|
||||
const selectedCommand = useWatch({ name: "command" });
|
||||
const showArgs = selectedCommand && serverCommands[selectedCommand]?.args === true;
|
||||
|
||||
if (!showArgs) return null;
|
||||
|
||||
return <TextInput required source="args" label="Arguments" fullWidth multiline />;
|
||||
};
|
||||
|
||||
const RecurringCommandEdit = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const notify = useNotify();
|
||||
const dataProvider = useDataProvider();
|
||||
const queryClient = useQueryClient();
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const [command, setCommand] = useState<RecurringCommand | undefined>(undefined);
|
||||
const isCreating = typeof id === "undefined";
|
||||
const [loading, setLoading] = useState(!isCreating);
|
||||
const { data: recurringCommands, isLoading: isLoadingList } = useRecurringCommands();
|
||||
const { serverCommands, isLoading: isLoadingServerCommands } = useServerCommands();
|
||||
const pageTitle = isCreating ? "Create Recurring Command" : "Edit Recurring Command";
|
||||
|
||||
const commandChoices = transformCommandsToChoices(serverCommands);
|
||||
const dayOfWeekChoices = [
|
||||
{ id: "Monday", name: "Monday" },
|
||||
{ id: "Tuesday", name: "Tuesday" },
|
||||
{ id: "Wednesday", name: "Wednesday" },
|
||||
{ id: "Thursday", name: "Thursday" },
|
||||
{ id: "Friday", name: "Friday" },
|
||||
{ id: "Saturday", name: "Saturday" },
|
||||
{ id: "Sunday", name: "Sunday" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreating && recurringCommands) {
|
||||
const commandToEdit = recurringCommands.find(cmd => cmd.id === id);
|
||||
if (commandToEdit) {
|
||||
const timeValue = commandToEdit.time || "";
|
||||
const timeParts = timeValue.split(" ");
|
||||
|
||||
const parsedCommand = {
|
||||
...commandToEdit,
|
||||
day_of_week: timeParts.length > 1 ? timeParts[0] : "Monday",
|
||||
execution_time: timeParts.length > 1 ? timeParts[1] : timeValue,
|
||||
};
|
||||
|
||||
setCommand(parsedCommand);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, recurringCommands, isCreating]);
|
||||
|
||||
const handleSubmit = async data => {
|
||||
try {
|
||||
// Format the time from the Date object to a string in HH:MM format
|
||||
let formattedTime = "00:00";
|
||||
|
||||
if (data.execution_time instanceof Date) {
|
||||
const hours = String(data.execution_time.getHours()).padStart(2, "0");
|
||||
const minutes = String(data.execution_time.getMinutes()).padStart(2, "0");
|
||||
formattedTime = `${hours}:${minutes}`;
|
||||
} else if (typeof data.execution_time === "string") {
|
||||
formattedTime = data.execution_time;
|
||||
}
|
||||
|
||||
const submissionData = {
|
||||
...data,
|
||||
time: `${data.day_of_week} ${formattedTime}`,
|
||||
};
|
||||
|
||||
delete submissionData.day_of_week;
|
||||
delete submissionData.execution_time;
|
||||
delete submissionData.scheduled_at;
|
||||
|
||||
// Only include args when it's required for the selected command
|
||||
const selectedCommand = data.command;
|
||||
if (!selectedCommand || !serverCommands[selectedCommand]?.args) {
|
||||
delete submissionData.args;
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
if (isCreating) {
|
||||
result = await dataProvider.createRecurringCommand(etkeccAdmin, submissionData);
|
||||
notify("recurring_commands.action.create_success", { type: "success" });
|
||||
} else {
|
||||
result = await dataProvider.updateRecurringCommand(etkeccAdmin, {
|
||||
...submissionData,
|
||||
id: id,
|
||||
});
|
||||
notify("recurring_commands.action.update_success", { type: "success" });
|
||||
}
|
||||
|
||||
// Invalidate scheduled commands queries
|
||||
queryClient.invalidateQueries({ queryKey: ["scheduledCommands"] });
|
||||
|
||||
navigate("/server_actions");
|
||||
} catch (error) {
|
||||
notify("recurring_commands.action.update_failure", { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || isLoadingList || isLoadingServerCommands) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button label="Back" onClick={() => navigate("/server_actions")} startIcon={<ArrowBackIcon />} sx={{ mb: 2 }} />
|
||||
|
||||
<Card>
|
||||
<CardHeader title={pageTitle} />
|
||||
<CardContent>
|
||||
{command && (
|
||||
<Alert severity="info">
|
||||
<Typography variant="body1" sx={{ px: 2 }}>
|
||||
You can find more details about the command{" "}
|
||||
<Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
<Form
|
||||
defaultValues={command || undefined}
|
||||
onSubmit={handleSubmit}
|
||||
record={command || undefined}
|
||||
warnWhenUnsavedChanges
|
||||
>
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
{!isCreating && <TextInput readOnly source="id" label="ID" fullWidth required />}
|
||||
<SelectInput source="command" choices={commandChoices} label="Command" fullWidth required />
|
||||
<ArgumentsField serverCommands={serverCommands} />
|
||||
<SelectInput source="day_of_week" choices={dayOfWeekChoices} label="Day of Week" fullWidth required />
|
||||
<TimeInput source="execution_time" label="Time (UTC)" fullWidth required />
|
||||
<Box mt={2} display="flex" justifyContent="space-between">
|
||||
<SaveButton label={isCreating ? "Create" : "Update"} />
|
||||
{!isCreating && <RecurringDeleteButton />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecurringCommandEdit;
|
||||
@@ -0,0 +1,62 @@
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { Paper } from "@mui/material";
|
||||
import { Loading, Button } from "react-admin";
|
||||
import { DateField } from "react-admin";
|
||||
import { Datagrid } from "react-admin";
|
||||
import { ListContextProvider, TextField, TopToolbar, Identifier } from "react-admin";
|
||||
import { ResourceContextProvider, useList } from "react-admin";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { DATE_FORMAT } from "../../../../../utils/date";
|
||||
import { useRecurringCommands } from "../../hooks/useRecurringCommands";
|
||||
|
||||
const ListActions = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<TopToolbar>
|
||||
<Button label="Create" onClick={() => navigate("/server_actions/recurring/create")} startIcon={<AddIcon />} />
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const RecurringCommandsList = () => {
|
||||
const { data, isLoading, error } = useRecurringCommands();
|
||||
|
||||
const listContext = useList({
|
||||
resource: "recurring",
|
||||
sort: { field: "scheduled_at", order: "DESC" },
|
||||
perPage: 50,
|
||||
data: data || [],
|
||||
isLoading: isLoading,
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
|
||||
return (
|
||||
<ResourceContextProvider value="recurring">
|
||||
<ListContextProvider value={listContext}>
|
||||
<ListActions />
|
||||
<Paper>
|
||||
<Datagrid
|
||||
bulkActionButtons={false}
|
||||
rowClick={(id: Identifier, resource: string, record: any) => {
|
||||
if (!record) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `/server_actions/${resource}/${id}`;
|
||||
}}
|
||||
>
|
||||
<TextField source="command" />
|
||||
<TextField source="args" label="Arguments" />
|
||||
<TextField source="time" label="Time (UTC)" />
|
||||
<DateField options={DATE_FORMAT} showTime source="scheduled_at" label="Next run at (local time)" />
|
||||
</Datagrid>
|
||||
</Paper>
|
||||
</ListContextProvider>
|
||||
</ResourceContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecurringCommandsList;
|
||||
@@ -0,0 +1,65 @@
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useState } from "react";
|
||||
import { useNotify, useDataProvider, useRecordContext } from "react-admin";
|
||||
import { Button, Confirm } from "react-admin";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAppContext } from "../../../../../Context";
|
||||
import { RecurringCommand } from "../../../../../synapse/dataProvider";
|
||||
|
||||
const RecurringDeleteButton = () => {
|
||||
const record = useRecordContext() as RecurringCommand;
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const dataProvider = useDataProvider();
|
||||
const notify = useNotify();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleClick = e => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await dataProvider.deleteRecurringCommand(etkeccAdmin, record.id);
|
||||
notify("recurring_commands.action.delete_success", { type: "success" });
|
||||
navigate("/server_actions");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
notify(`Error: ${errorMessage}`, { type: "error" });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
sx={{ color: theme.palette.error.main }}
|
||||
label="Delete"
|
||||
onClick={handleClick}
|
||||
disabled={isDeleting}
|
||||
startIcon={<DeleteIcon />}
|
||||
/>
|
||||
<Confirm
|
||||
isOpen={open}
|
||||
title="Delete Recurring Command"
|
||||
content={`Are you sure you want to delete the command: ${record?.command || ""}?`}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleCancel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecurringDeleteButton;
|
||||
@@ -0,0 +1,141 @@
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import { Card, CardContent, CardHeader, Box } from "@mui/material";
|
||||
import { Typography, Link } from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Form,
|
||||
TextInput,
|
||||
DateTimeInput,
|
||||
SaveButton,
|
||||
useNotify,
|
||||
useDataProvider,
|
||||
Loading,
|
||||
Button,
|
||||
BooleanInput,
|
||||
SelectInput,
|
||||
} from "react-admin";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
|
||||
import ScheduleDeleteButton from "./ScheduledDeleteButton";
|
||||
import { useAppContext } from "../../../../../Context";
|
||||
import { ScheduledCommand } from "../../../../../synapse/dataProvider";
|
||||
import { useServerCommands } from "../../../hooks/useServerCommands";
|
||||
import { useScheduledCommands } from "../../hooks/useScheduledCommands";
|
||||
|
||||
const transformCommandsToChoices = (commands: Record<string, any>) => {
|
||||
return Object.entries(commands).map(([key, value]) => ({
|
||||
id: key,
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
}));
|
||||
};
|
||||
|
||||
const ArgumentsField = ({ serverCommands }) => {
|
||||
const selectedCommand = useWatch({ name: "command" });
|
||||
const showArgs = selectedCommand && serverCommands[selectedCommand]?.args === true;
|
||||
|
||||
if (!showArgs) return null;
|
||||
|
||||
return <TextInput required source="args" label="Arguments" fullWidth multiline />;
|
||||
};
|
||||
|
||||
const ScheduledCommandEdit = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const notify = useNotify();
|
||||
const dataProvider = useDataProvider();
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const [command, setCommand] = useState<ScheduledCommand | null>(null);
|
||||
const isCreating = typeof id === "undefined";
|
||||
const [loading, setLoading] = useState(!isCreating);
|
||||
const { data: scheduledCommands, isLoading: isLoadingList } = useScheduledCommands();
|
||||
const { serverCommands, isLoading: isLoadingServerCommands } = useServerCommands();
|
||||
const pageTitle = isCreating ? "Create Scheduled Command" : "Edit Scheduled Command";
|
||||
|
||||
const commandChoices = transformCommandsToChoices(serverCommands);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreating && scheduledCommands) {
|
||||
const commandToEdit = scheduledCommands.find(cmd => cmd.id === id);
|
||||
if (commandToEdit) {
|
||||
setCommand(commandToEdit);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, scheduledCommands, isCreating]);
|
||||
|
||||
const handleSubmit = async data => {
|
||||
try {
|
||||
let result;
|
||||
|
||||
data.scheduled_at = new Date(data.scheduled_at).toISOString();
|
||||
|
||||
if (isCreating) {
|
||||
result = await dataProvider.createScheduledCommand(etkeccAdmin, data);
|
||||
notify("scheduled_commands.action.create_success", { type: "success" });
|
||||
} else {
|
||||
result = await dataProvider.updateScheduledCommand(etkeccAdmin, {
|
||||
...data,
|
||||
id: id,
|
||||
});
|
||||
notify("scheduled_commands.action.update_success", { type: "success" });
|
||||
}
|
||||
|
||||
navigate("/server_actions");
|
||||
} catch (error) {
|
||||
notify("scheduled_commands.action.update_failure", { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || isLoadingList) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button label="Back" onClick={() => navigate("/server_actions")} startIcon={<ArrowBackIcon />} sx={{ mb: 2 }} />
|
||||
|
||||
<Card>
|
||||
<CardHeader title={pageTitle} />
|
||||
{command && (
|
||||
<Typography variant="body1" sx={{ px: 2 }}>
|
||||
You can find more details about the command{" "}
|
||||
<Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
)}
|
||||
<CardContent>
|
||||
<Form
|
||||
defaultValues={command || undefined}
|
||||
onSubmit={handleSubmit}
|
||||
record={command || undefined}
|
||||
warnWhenUnsavedChanges
|
||||
>
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
{command && <TextInput readOnly source="id" label="ID" fullWidth required />}
|
||||
<SelectInput
|
||||
readOnly={!isCreating}
|
||||
source="command"
|
||||
choices={commandChoices}
|
||||
label="Command"
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<ArgumentsField serverCommands={serverCommands} />
|
||||
<DateTimeInput source="scheduled_at" label="Scheduled at" fullWidth required />
|
||||
<Box mt={2} display="flex" justifyContent="space-between">
|
||||
<SaveButton label={isCreating ? "Create" : "Update"} />
|
||||
{!isCreating && <ScheduleDeleteButton />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduledCommandEdit;
|
||||
@@ -0,0 +1,89 @@
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import { Alert, Box, Card, CardContent, CardHeader, Typography, Link } from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Loading,
|
||||
Button,
|
||||
useDataProvider,
|
||||
useNotify,
|
||||
SimpleShowLayout,
|
||||
TextField,
|
||||
BooleanField,
|
||||
DateField,
|
||||
RecordContextProvider,
|
||||
} from "react-admin";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
|
||||
import ScheduledDeleteButton from "./ScheduledDeleteButton";
|
||||
import { useAppContext } from "../../../../../Context";
|
||||
import { ScheduledCommand } from "../../../../../synapse/dataProvider";
|
||||
import { useScheduledCommands } from "../../hooks/useScheduledCommands";
|
||||
|
||||
const ScheduledCommandShow = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [command, setCommand] = useState<ScheduledCommand | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data: scheduledCommands, isLoading: isLoadingList } = useScheduledCommands();
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduledCommands) {
|
||||
const commandToShow = scheduledCommands.find(cmd => cmd.id === id);
|
||||
if (commandToShow) {
|
||||
setCommand(commandToShow);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, scheduledCommands]);
|
||||
|
||||
if (loading || isLoadingList) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button label="Back" onClick={() => navigate("/server_actions")} startIcon={<ArrowBackIcon />} sx={{ mb: 2 }} />
|
||||
|
||||
<RecordContextProvider value={command}>
|
||||
<Card>
|
||||
<CardHeader title="Scheduled Command Details" />
|
||||
<CardContent>
|
||||
{command && (
|
||||
<Alert severity="info">
|
||||
<Typography variant="body1" sx={{ px: 2 }}>
|
||||
You can find more details about the command{" "}
|
||||
<Link href={`https://etke.cc/help/extras/scheduler/#${command.command}`} target="_blank">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
<SimpleShowLayout>
|
||||
<TextField source="id" label="ID" />
|
||||
<TextField source="command" label="Command" />
|
||||
{command.args && <TextField source="args" label="Arguments" />}
|
||||
<BooleanField source="is_recurring" label="Is recurring" />
|
||||
<DateField source="scheduled_at" label="Scheduled at" showTime />
|
||||
</SimpleShowLayout>
|
||||
{command.is_recurring && (
|
||||
<Alert severity="warning">
|
||||
Scheduled commands created from a recurring one are not editable as they will be regenerated
|
||||
automatically. Please edit the recurring command instead.
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Box display="flex" justifyContent="flex-end" mt={2}>
|
||||
<ScheduledDeleteButton />
|
||||
</Box>
|
||||
</RecordContextProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduledCommandShow;
|
||||
@@ -0,0 +1,72 @@
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { Paper } from "@mui/material";
|
||||
import { Loading, Button, useNotify, useRefresh, useCreatePath, useRecordContext } from "react-admin";
|
||||
import { ResourceContextProvider, useList } from "react-admin";
|
||||
import { ListContextProvider, TextField } from "react-admin";
|
||||
import { Datagrid } from "react-admin";
|
||||
import { BooleanField, DateField, TopToolbar } from "react-admin";
|
||||
import { useDataProvider } from "react-admin";
|
||||
import { Identifier } from "react-admin";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAppContext } from "../../../../../Context";
|
||||
import { DATE_FORMAT } from "../../../../../utils/date";
|
||||
import { useScheduledCommands } from "../../hooks/useScheduledCommands";
|
||||
const ListActions = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate("/server_actions/scheduled/create");
|
||||
};
|
||||
|
||||
return (
|
||||
<TopToolbar>
|
||||
<Button label="Create" onClick={handleCreate} startIcon={<AddIcon />} />
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const ScheduledCommandsList = () => {
|
||||
const { data, isLoading, error } = useScheduledCommands();
|
||||
|
||||
const listContext = useList({
|
||||
resource: "scheduled",
|
||||
sort: { field: "scheduled_at", order: "DESC" },
|
||||
perPage: 50,
|
||||
data: data || [],
|
||||
isLoading: isLoading,
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
|
||||
return (
|
||||
<ResourceContextProvider value="scheduled">
|
||||
<ListContextProvider value={listContext}>
|
||||
<ListActions />
|
||||
<Paper>
|
||||
<Datagrid
|
||||
bulkActionButtons={false}
|
||||
rowClick={(id: Identifier, resource: string, record: any) => {
|
||||
if (!record) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (record.is_recurring) {
|
||||
return `/server_actions/${resource}/${id}/show`;
|
||||
}
|
||||
|
||||
return `/server_actions/${resource}/${id}`;
|
||||
}}
|
||||
>
|
||||
<TextField source="command" />
|
||||
<TextField source="args" label="Arguments" />
|
||||
<BooleanField source="is_recurring" label="Is recurring?" />
|
||||
<DateField options={DATE_FORMAT} showTime source="scheduled_at" label="Run at (local time)" />
|
||||
</Datagrid>
|
||||
</Paper>
|
||||
</ListContextProvider>
|
||||
</ResourceContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduledCommandsList;
|
||||
@@ -0,0 +1,65 @@
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useState } from "react";
|
||||
import { useNotify, useDataProvider, useRecordContext } from "react-admin";
|
||||
import { Button, Confirm } from "react-admin";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAppContext } from "../../../../../Context";
|
||||
import { ScheduledCommand } from "../../../../../synapse/dataProvider";
|
||||
|
||||
const ScheduledDeleteButton = () => {
|
||||
const record = useRecordContext() as ScheduledCommand;
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const dataProvider = useDataProvider();
|
||||
const notify = useNotify();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleClick = e => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await dataProvider.deleteScheduledCommand(etkeccAdmin, record.id);
|
||||
notify("scheduled_commands.action.delete_success", { type: "success" });
|
||||
navigate("/server_actions");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
notify(`Error: ${errorMessage}`, { type: "error" });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
sx={{ color: theme.palette.error.main }}
|
||||
label="Delete"
|
||||
onClick={handleClick}
|
||||
disabled={isDeleting}
|
||||
startIcon={<DeleteIcon />}
|
||||
/>
|
||||
<Confirm
|
||||
isOpen={open}
|
||||
title="Delete Scheduled Command"
|
||||
content={`Are you sure you want to delete the command: ${record?.command || ""}?`}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleCancel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduledDeleteButton;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDataProvider } from "react-admin";
|
||||
|
||||
import { useAppContext } from "../../../../Context";
|
||||
|
||||
export const useRecurringCommands = () => {
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const dataProvider = useDataProvider();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["recurringCommands"],
|
||||
queryFn: () => dataProvider.getRecurringCommands(etkeccAdmin),
|
||||
});
|
||||
|
||||
return { data, isLoading, error };
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDataProvider } from "react-admin";
|
||||
|
||||
import { useAppContext } from "../../../../Context";
|
||||
|
||||
export const useScheduledCommands = () => {
|
||||
const { etkeccAdmin } = useAppContext();
|
||||
const dataProvider = useDataProvider();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["scheduledCommands"],
|
||||
queryFn: () => dataProvider.getScheduledCommands(etkeccAdmin),
|
||||
});
|
||||
|
||||
return { data, isLoading, error };
|
||||
};
|
||||
Reference in New Issue
Block a user