(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:
parent
e2d3c0792b
commit
0832c43d76
@ -122,6 +122,7 @@ The following list contains such features - they are only available for [etke.cc
|
||||
* 📊 [Server Status indicator and page](https://github.com/etkecc/synapse-admin/pull/182)
|
||||
* 📬 [Server Notifications indicator and page](https://github.com/etkecc/synapse-admin/pull/240)
|
||||
* 🛠️ [Server Commands panel](https://github.com/etkecc/synapse-admin/pull/365)
|
||||
* 🚀 [Server Actions page](https://github.com/etkecc/synapse-admin/pull/457)
|
||||
|
||||
### Development
|
||||
|
||||
|
BIN
screenshots/etke.cc/server-actions/page.webp
Normal file
BIN
screenshots/etke.cc/server-actions/page.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 88 KiB |
10
src/App.tsx
10
src/App.tsx
@ -5,8 +5,12 @@ import { Admin, CustomRoutes, Resource, resolveBrowserLocale } from "react-admin
|
||||
import { Route } from "react-router-dom";
|
||||
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import ServerActionsPage from "./components/etke.cc/ServerActionsPage";
|
||||
import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage";
|
||||
import ServerStatusPage from "./components/etke.cc/ServerStatusPage";
|
||||
import RecurringCommandEdit from "./components/etke.cc/schedules/components/recurring/RecurringCommandEdit";
|
||||
import ScheduledCommandEdit from "./components/etke.cc/schedules/components/scheduled/ScheduledCommandEdit";
|
||||
import ScheduledCommandShow from "./components/etke.cc/schedules/components/scheduled/ScheduledCommandShow";
|
||||
import UserImport from "./components/user-import/UserImport";
|
||||
import germanMessages from "./i18n/de";
|
||||
import englishMessages from "./i18n/en";
|
||||
@ -64,6 +68,12 @@ export const App = () => (
|
||||
<CustomRoutes>
|
||||
<Route path="/import_users" element={<UserImport />} />
|
||||
<Route path="/server_status" element={<ServerStatusPage />} />
|
||||
<Route path="/server_actions" element={<ServerActionsPage />} />
|
||||
<Route path="/server_actions/scheduled/:id/show" element={<ScheduledCommandShow />} />
|
||||
<Route path="/server_actions/scheduled/:id" element={<ScheduledCommandEdit />} />
|
||||
<Route path="/server_actions/scheduled/create" element={<ScheduledCommandEdit />} />
|
||||
<Route path="/server_actions/recurring/:id" element={<RecurringCommandEdit />} />
|
||||
<Route path="/server_actions/recurring/create" element={<RecurringCommandEdit />} />
|
||||
<Route path="/server_notifications" element={<ServerNotificationsPage />} />
|
||||
</CustomRoutes>
|
||||
<Resource {...users} />
|
||||
|
@ -1,3 +1,4 @@
|
||||
import ManageHistoryIcon from "@mui/icons-material/ManageHistory";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import {
|
||||
CheckForApplicationUpdate,
|
||||
@ -75,11 +76,11 @@ const AdminAppBar = () => {
|
||||
|
||||
const AdminMenu = props => {
|
||||
const [menu, setMenu] = useState([] as MenuItem[]);
|
||||
const [serverStatusEnabled, setServerStatusEnabled] = useState(false);
|
||||
const [etkeRoutesEnabled, setEtkeRoutesEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
setMenu(GetConfig().menu);
|
||||
if (GetConfig().etkeccAdmin) {
|
||||
setServerStatusEnabled(true);
|
||||
setEtkeRoutesEnabled(true);
|
||||
}
|
||||
}, []);
|
||||
const [serverProcess, setServerProcess] = useStore<ServerProcessResponse>("serverProcess", {
|
||||
@ -95,8 +96,9 @@ const AdminMenu = props => {
|
||||
|
||||
return (
|
||||
<Menu {...props}>
|
||||
{serverStatusEnabled && (
|
||||
{etkeRoutesEnabled && (
|
||||
<Menu.Item
|
||||
key="server_status"
|
||||
to="/server_status"
|
||||
leftIcon={
|
||||
<ServerStatusStyledBadge
|
||||
@ -104,11 +106,20 @@ const AdminMenu = props => {
|
||||
command={serverProcess.command}
|
||||
locked_at={serverProcess.locked_at}
|
||||
isOkay={serverStatus.ok}
|
||||
isLoaded={serverStatus.success}
|
||||
/>
|
||||
}
|
||||
primaryText="Server Status"
|
||||
/>
|
||||
)}
|
||||
{etkeRoutesEnabled && (
|
||||
<Menu.Item
|
||||
key="server_actions"
|
||||
to="/server_actions"
|
||||
leftIcon={<ManageHistoryIcon />}
|
||||
primaryText="Server Actions"
|
||||
/>
|
||||
)}
|
||||
<Menu.ResourceItems />
|
||||
{menu &&
|
||||
menu.map((item, index) => {
|
||||
|
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 };
|
||||
};
|
@ -482,5 +482,23 @@ const en: SynapseTranslationMessages = {
|
||||
helper: { length: "Length of the token if no token is given." },
|
||||
},
|
||||
},
|
||||
scheduled_commands: {
|
||||
action: {
|
||||
create_success: "Scheduled command created successfully",
|
||||
update_success: "Scheduled command updated successfully",
|
||||
update_failure: "An error has occurred",
|
||||
delete_success: "Scheduled command deleted successfully",
|
||||
delete_failure: "An error has occurred",
|
||||
},
|
||||
},
|
||||
recurring_commands: {
|
||||
action: {
|
||||
create_success: "Recurring command created successfully",
|
||||
update_success: "Recurring command updated successfully",
|
||||
update_failure: "An error has occurred",
|
||||
delete_success: "Recurring command deleted successfully",
|
||||
delete_failure: "An error has occurred",
|
||||
},
|
||||
},
|
||||
};
|
||||
export default en;
|
||||
|
18
src/i18n/index.d.ts
vendored
18
src/i18n/index.d.ts
vendored
@ -473,4 +473,22 @@ interface SynapseTranslationMessages extends TranslationMessages {
|
||||
};
|
||||
};
|
||||
};
|
||||
scheduled_commands?: {
|
||||
action: {
|
||||
create_success: string;
|
||||
update_success: string;
|
||||
update_failure: string;
|
||||
delete_success: string;
|
||||
delete_failure: string;
|
||||
};
|
||||
};
|
||||
recurring_commands?: {
|
||||
action: {
|
||||
create_success: string;
|
||||
update_success: string;
|
||||
update_failure: string;
|
||||
delete_success: string;
|
||||
delete_failure: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
@ -36,7 +36,14 @@ describe("LoginForm", () => {
|
||||
it("renders with single restricted homeserver", () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AppContext.Provider value={{ restrictBaseUrl: "https://matrix.example.com", asManagedUsers: [], menu: [] }}>
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
restrictBaseUrl: "https://matrix.example.com",
|
||||
asManagedUsers: [],
|
||||
menu: [],
|
||||
corsCredentials: "include",
|
||||
}}
|
||||
>
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
<LoginPage />
|
||||
</AdminContext>
|
||||
@ -62,6 +69,7 @@ describe("LoginForm", () => {
|
||||
restrictBaseUrl: ["https://matrix.example.com", "https://matrix.example.org"],
|
||||
asManagedUsers: [],
|
||||
menu: [],
|
||||
corsCredentials: "include",
|
||||
}}
|
||||
>
|
||||
<AdminContext i18nProvider={i18nProvider}>
|
||||
|
@ -87,6 +87,7 @@ describe("authProvider", () => {
|
||||
expect(fetch).toHaveBeenCalledWith("example.com/_matrix/client/v3/logout", {
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer foo",
|
||||
}),
|
||||
method: "POST",
|
||||
|
@ -23,7 +23,11 @@ const authProvider: AuthProvider = {
|
||||
console.log("login ");
|
||||
let options: Options = {
|
||||
method: "POST",
|
||||
credentials: GetConfig().corsCredentials,
|
||||
credentials: GetConfig().corsCredentials as RequestCredentials,
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify(
|
||||
Object.assign(
|
||||
{
|
||||
@ -150,7 +154,11 @@ const authProvider: AuthProvider = {
|
||||
|
||||
const options: Options = {
|
||||
method: "POST",
|
||||
credentials: GetConfig().corsCredentials,
|
||||
credentials: GetConfig().corsCredentials as RequestCredentials,
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
user: {
|
||||
authenticated: true,
|
||||
token: `Bearer ${access_token}`,
|
||||
|
@ -23,7 +23,7 @@ const CACHED_MANY_REF: Record<string, any> = {};
|
||||
const jsonClient = async (url: string, options: Options = {}) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
console.log("httpClient " + url);
|
||||
options.credentials = GetConfig().corsCredentials;
|
||||
options.credentials = GetConfig().corsCredentials as RequestCredentials;
|
||||
if (token !== null) {
|
||||
options.user = {
|
||||
authenticated: true,
|
||||
@ -322,6 +322,22 @@ export interface ServerCommand {
|
||||
|
||||
export type ServerCommandsResponse = Record<string, ServerCommand>;
|
||||
|
||||
export interface ScheduledCommand {
|
||||
args: string;
|
||||
command: string;
|
||||
id: string;
|
||||
is_recurring: boolean;
|
||||
scheduled_at: string;
|
||||
}
|
||||
|
||||
export interface RecurringCommand {
|
||||
args: string;
|
||||
command: string;
|
||||
id: string;
|
||||
scheduled_at: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface SynapseDataProvider extends DataProvider {
|
||||
deleteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||
purgeRemoteMedia: (params: DeleteMediaParams) => Promise<DeleteMediaResult>;
|
||||
@ -337,6 +353,14 @@ export interface SynapseDataProvider extends DataProvider {
|
||||
getServerNotifications: (etkeAdminUrl: string) => Promise<ServerNotificationsResponse>;
|
||||
deleteServerNotifications: (etkeAdminUrl: string) => Promise<{ success: boolean }>;
|
||||
getServerCommands: (etkeAdminUrl: string) => Promise<ServerCommandsResponse>;
|
||||
getScheduledCommands: (etkeAdminUrl: string) => Promise<ScheduledCommand[]>;
|
||||
getRecurringCommands: (etkeAdminUrl: string) => Promise<RecurringCommand[]>;
|
||||
createScheduledCommand: (etkeAdminUrl: string, command: Partial<ScheduledCommand>) => Promise<ScheduledCommand>;
|
||||
updateScheduledCommand: (etkeAdminUrl: string, command: ScheduledCommand) => Promise<ScheduledCommand>;
|
||||
deleteScheduledCommand: (etkeAdminUrl: string, id: string) => Promise<{ success: boolean }>;
|
||||
createRecurringCommand: (etkeAdminUrl: string, command: Partial<RecurringCommand>) => Promise<RecurringCommand>;
|
||||
updateRecurringCommand: (etkeAdminUrl: string, command: RecurringCommand) => Promise<RecurringCommand>;
|
||||
deleteRecurringCommand: (etkeAdminUrl: string, id: string) => Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
const resourceMap = {
|
||||
@ -1188,6 +1212,212 @@ const baseDataProvider: SynapseDataProvider = {
|
||||
success: false,
|
||||
};
|
||||
},
|
||||
getScheduledCommands: async (scheduledCommandsUrl: string) => {
|
||||
try {
|
||||
const response = await fetch(`${scheduledCommandsUrl}/schedules`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error fetching scheduled commands: ${response.status} ${response.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
return json as ScheduledCommand[];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching scheduled commands, error");
|
||||
}
|
||||
return [];
|
||||
},
|
||||
getRecurringCommands: async (recurringCommandsUrl: string) => {
|
||||
try {
|
||||
const response = await fetch(`${recurringCommandsUrl}/recurrings`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Error fetching recurring commands: ${response.status} ${response.statusText}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
|
||||
if (status === 200) {
|
||||
const json = await response.json();
|
||||
return json as RecurringCommand[];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching recurring commands, error");
|
||||
}
|
||||
return [];
|
||||
},
|
||||
createScheduledCommand: async (scheduledCommandsUrl: string, command: Partial<ScheduledCommand>) => {
|
||||
try {
|
||||
const response = await fetch(`${scheduledCommandsUrl}/schedules`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error creating scheduled command: ${response.status} ${response.statusText}`);
|
||||
throw new Error("Failed to create scheduled command");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return command as ScheduledCommand;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json as ScheduledCommand;
|
||||
} catch (error) {
|
||||
console.error("Error creating scheduled command", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
updateScheduledCommand: async (scheduledCommandsUrl: string, command: ScheduledCommand) => {
|
||||
try {
|
||||
// Use the base endpoint without ID and use PUT for upsert
|
||||
const response = await fetch(`${scheduledCommandsUrl}/schedules`, {
|
||||
method: "PUT", // Using PUT on the base endpoint
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const jsonErr = JSON.parse(await response.text());
|
||||
console.error(`Error updating scheduled command: ${response.status} ${response.statusText}`);
|
||||
throw new Error(jsonErr.error);
|
||||
}
|
||||
|
||||
// According to docs, successful response is 204 No Content
|
||||
if (response.status === 204) {
|
||||
// Return the command object we sent since the server doesn't return data
|
||||
return command;
|
||||
}
|
||||
|
||||
// If server does return data (though docs suggest it returns 204)
|
||||
const json = await response.json();
|
||||
console.log("JSON", json);
|
||||
return json as ScheduledCommand;
|
||||
} catch (error) {
|
||||
console.error("Error updating scheduled command", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
deleteScheduledCommand: async (scheduledCommandsUrl: string, id: string) => {
|
||||
try {
|
||||
const response = await fetch(`${scheduledCommandsUrl}/schedules/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error deleting scheduled command: ${response.status} ${response.statusText}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting scheduled command", error);
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
createRecurringCommand: async (recurringCommandsUrl: string, command: Partial<RecurringCommand>) => {
|
||||
try {
|
||||
const response = await fetch(`${recurringCommandsUrl}/recurrings`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error creating recurring command: ${response.status} ${response.statusText}`);
|
||||
throw new Error("Failed to create recurring command");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
// Return the command object we sent since the server doesn't return data
|
||||
return command as RecurringCommand;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json as RecurringCommand;
|
||||
} catch (error) {
|
||||
console.error("Error creating recurring command", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
updateRecurringCommand: async (recurringCommandsUrl: string, command: RecurringCommand) => {
|
||||
try {
|
||||
const response = await fetch(`${recurringCommandsUrl}/recurrings`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error updating recurring command: ${response.status} ${response.statusText}`);
|
||||
throw new Error("Failed to update recurring command");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
// Return the command object we sent since the server doesn't return data
|
||||
return command as RecurringCommand;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json as RecurringCommand;
|
||||
} catch (error) {
|
||||
console.error("Error updating recurring command", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
deleteRecurringCommand: async (recurringCommandsUrl: string, id: string) => {
|
||||
try {
|
||||
const response = await fetch(`${recurringCommandsUrl}/recurrings/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error deleting recurring command: ${response.status} ${response.statusText}`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting recurring command", error);
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const dataProvider = withLifecycleCallbacks(baseDataProvider, [
|
||||
|
Loading…
x
Reference in New Issue
Block a user