feat(ui): schedules management page
Add /schedules page with list view (name, kind, definition summary, timezone, enabled, next run) and create/edit/delete/enable-disable actions. ScheduleFormDialog supports both Easy (interval + Minutes/Hours/Days) and Cron (six-field expression) kinds with UTC/Local timezone selection, matching the backend ScheduleRequest/Response shape.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { SchedulesApi } from "@/lib/api/resources";
|
||||
import type { Schedule } from "@/lib/api/types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
schedule: Schedule | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const KINDS = [
|
||||
{ value: "Easy", label: "Easy (fixed interval)" },
|
||||
{ value: "Cron", label: "Cron expression" },
|
||||
];
|
||||
|
||||
const UNITS = ["Minutes", "Hours", "Days"];
|
||||
const TIMEZONES = ["UTC", "Local"];
|
||||
|
||||
export default function ScheduleFormDialog({ open, schedule, onClose, onSaved }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isEnabled, setIsEnabled] = useState(true);
|
||||
const [scheduleKind, setScheduleKind] = useState("Easy");
|
||||
const [easyIntervalValue, setEasyIntervalValue] = useState<number | "">(5);
|
||||
const [easyIntervalUnit, setEasyIntervalUnit] = useState("Minutes");
|
||||
const [cronExpression, setCronExpression] = useState("");
|
||||
const [timezoneMode, setTimezoneMode] = useState("UTC");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
if (schedule) {
|
||||
setName(schedule.name);
|
||||
setDescription(schedule.description ?? "");
|
||||
setIsEnabled(schedule.isEnabled);
|
||||
setScheduleKind(schedule.scheduleKind || "Easy");
|
||||
setEasyIntervalValue(schedule.easyIntervalValue ?? 5);
|
||||
setEasyIntervalUnit(schedule.easyIntervalUnit ?? "Minutes");
|
||||
setCronExpression(schedule.cronExpression ?? "");
|
||||
setTimezoneMode(schedule.timezoneMode || "UTC");
|
||||
} else {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIsEnabled(true);
|
||||
setScheduleKind("Easy");
|
||||
setEasyIntervalValue(5);
|
||||
setEasyIntervalUnit("Minutes");
|
||||
setCronExpression("");
|
||||
setTimezoneMode("UTC");
|
||||
}
|
||||
}, [open, schedule]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return setError("Name is required");
|
||||
if (scheduleKind === "Easy" && (easyIntervalValue === "" || easyIntervalValue <= 0))
|
||||
return setError("Easy interval must be a positive number");
|
||||
if (scheduleKind === "Cron" && !cronExpression.trim())
|
||||
return setError("Cron expression is required");
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: Partial<Schedule> = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
isEnabled,
|
||||
scheduleKind,
|
||||
timezoneMode,
|
||||
easyIntervalValue: scheduleKind === "Easy" ? Number(easyIntervalValue) : undefined,
|
||||
easyIntervalUnit: scheduleKind === "Easy" ? easyIntervalUnit : undefined,
|
||||
cronExpression: scheduleKind === "Cron" ? cronExpression.trim() : undefined,
|
||||
};
|
||||
if (schedule) await SchedulesApi.update(schedule.id, body);
|
||||
else await SchedulesApi.create(body);
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Failed to save schedule");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{schedule ? "Edit Schedule" : "New Schedule"}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
<TextField label="Name" value={name} onChange={(e) => setName(e.target.value)} required fullWidth />
|
||||
<TextField label="Description" value={description} onChange={(e) => setDescription(e.target.value)} fullWidth multiline rows={2} />
|
||||
<FormControlLabel control={<Switch checked={isEnabled} onChange={(_, v) => setIsEnabled(v)} />} label="Enabled" />
|
||||
|
||||
<TextField select label="Kind" value={scheduleKind} onChange={(e) => setScheduleKind(e.target.value)} fullWidth>
|
||||
{KINDS.map((k) => (
|
||||
<MenuItem key={k.value} value={k.value}>{k.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{scheduleKind === "Easy" ? (
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
label="Every"
|
||||
type="number"
|
||||
value={easyIntervalValue}
|
||||
onChange={(e) => setEasyIntervalValue(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField select label="Unit" value={easyIntervalUnit} onChange={(e) => setEasyIntervalUnit(e.target.value)} fullWidth>
|
||||
{UNITS.map((u) => (
|
||||
<MenuItem key={u} value={u}>{u}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack spacing={0.5}>
|
||||
<TextField
|
||||
label="Cron Expression"
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
fullWidth
|
||||
placeholder="0 */5 * * * *"
|
||||
/>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Six fields: seconds minutes hours day-of-month month day-of-week
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TextField select label="Timezone" value={timezoneMode} onChange={(e) => setTimezoneMode(e.target.value)} fullWidth>
|
||||
{TIMEZONES.map((t) => (
|
||||
<MenuItem key={t} value={t}>{t}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} variant="contained" disabled={saving}>
|
||||
{saving ? "Saving..." : schedule ? "Save" : "Create"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import PageContainer from "@/app/components/container/PageContainer";
|
||||
import DashboardCard from "@/app/components/shared/DashboardCard";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { SchedulesApi } from "@/lib/api/resources";
|
||||
import type { Schedule } from "@/lib/api/types";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
import ScheduleFormDialog from "./ScheduleFormDialog";
|
||||
|
||||
export default function SchedulesPage() {
|
||||
const [items, setItems] = useState<Schedule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Schedule | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await SchedulesApi.list({ pageSize: 100 });
|
||||
setItems(res.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Failed to load schedules");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleToggle = async (s: Schedule) => {
|
||||
try {
|
||||
if (s.isEnabled) await SchedulesApi.disable(s.id);
|
||||
else await SchedulesApi.enable(s.id);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Failed to toggle schedule");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (s: Schedule) => {
|
||||
if (!window.confirm(`Delete schedule "${s.name}"?`)) return;
|
||||
try {
|
||||
await SchedulesApi.remove(s.id);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Failed to delete schedule");
|
||||
}
|
||||
};
|
||||
|
||||
const summarize = (s: Schedule) => {
|
||||
if (s.scheduleKind === "Cron") return s.cronExpression || "-";
|
||||
if (s.scheduleKind === "Easy" && s.easyIntervalValue && s.easyIntervalUnit)
|
||||
return `Every ${s.easyIntervalValue} ${s.easyIntervalUnit.toLowerCase()}`;
|
||||
return "-";
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title="Schedules" description="Reusable schedule definitions">
|
||||
<DashboardCard
|
||||
title="Schedules"
|
||||
subtitle="Easy intervals or six-field cron expressions reusable across rules"
|
||||
action={
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
New Schedule
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : items.length === 0 ? (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
No schedules yet. Click “New Schedule” to add one.
|
||||
</Typography>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Kind</TableCell>
|
||||
<TableCell>Definition</TableCell>
|
||||
<TableCell>Timezone</TableCell>
|
||||
<TableCell>Enabled</TableCell>
|
||||
<TableCell>Next Run</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((s) => (
|
||||
<TableRow key={s.id} hover>
|
||||
<TableCell>
|
||||
<Stack>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{s.name}
|
||||
</Typography>
|
||||
{s.description && (
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{s.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={s.scheduleKind} variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontFamily: "monospace" }}>
|
||||
{summarize(s)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{s.timezoneMode}</TableCell>
|
||||
<TableCell>
|
||||
<Switch size="small" checked={s.isEnabled} onChange={() => handleToggle(s)} />
|
||||
</TableCell>
|
||||
<TableCell>{formatDateTime(s.nextRunUtc)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => { setEditing(s); setFormOpen(true); }}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => handleDelete(s)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
</DashboardCard>
|
||||
|
||||
<ScheduleFormDialog
|
||||
open={formOpen}
|
||||
schedule={editing}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSaved={async () => { setFormOpen(false); await load(); }}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user