mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 11:04:07 +00:00
Merge remote-tracking branch 'origin/dev' into optimize-channels-store
This commit is contained in:
commit
cd6b3da8eb
47 changed files with 3417 additions and 649 deletions
230
frontend/src/components/forms/Connection.jsx
Normal file
230
frontend/src/components/forms/Connection.jsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Flex,
|
||||
TextInput,
|
||||
Box,
|
||||
Checkbox,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { SUBSCRIPTION_EVENTS } from '../../constants';
|
||||
|
||||
const EVENT_OPTIONS = Object.entries(SUBSCRIPTION_EVENTS).map(
|
||||
([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})
|
||||
);
|
||||
|
||||
const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedEvents, setSelectedEvents] = useState([]);
|
||||
const [apiError, setApiError] = useState('');
|
||||
|
||||
// One-time form
|
||||
const form = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
name: connection?.name || '',
|
||||
type: connection?.type || 'webhook',
|
||||
url: connection?.config?.url || '',
|
||||
script_path: connection?.config?.path || '',
|
||||
enabled: connection?.enabled ?? true,
|
||||
},
|
||||
validate: {
|
||||
name: isNotEmpty('Provide a name'),
|
||||
type: isNotEmpty('Select a type'),
|
||||
url: (value, values) => {
|
||||
if (values.type === 'webhook' && !value.trim()) {
|
||||
return 'Provide a webhook URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
script_path: (value, values) => {
|
||||
if (values.type === 'script' && !value.trim()) {
|
||||
return 'Provide a script path';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
const values = {
|
||||
name: connection.name,
|
||||
type: connection.type,
|
||||
url: connection.config?.url,
|
||||
script_path: connection.config?.path,
|
||||
enabled: connection.enabled,
|
||||
};
|
||||
form.setValues(values);
|
||||
setSelectedEvents(
|
||||
connection.subscriptions.reduce((acc, sub) => {
|
||||
if (sub.enabled) acc.push(sub.event);
|
||||
return acc;
|
||||
}, [])
|
||||
);
|
||||
} else {
|
||||
form.reset();
|
||||
setSelectedEvents([]);
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
const handleClose = () => {
|
||||
setApiError('');
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
console.log(values);
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setApiError('');
|
||||
const config =
|
||||
values.type === 'webhook'
|
||||
? { url: values.url }
|
||||
: { path: values.script_path };
|
||||
|
||||
if (connection) {
|
||||
await API.updateConnectIntegration(connection.id, {
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
config,
|
||||
enabled: values.enabled,
|
||||
});
|
||||
} else {
|
||||
connection = await API.createConnectIntegration({
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
config,
|
||||
enabled: values.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
await API.setConnectSubscriptions(
|
||||
connection.id,
|
||||
Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({
|
||||
event,
|
||||
enabled: selectedEvents.includes(event),
|
||||
}))
|
||||
);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to create/update connection', error);
|
||||
// Try to map server-side validation errors to form fields
|
||||
const body = error?.body;
|
||||
|
||||
if (body && typeof body === 'object') {
|
||||
const fieldErrors = {};
|
||||
if (body.name) {
|
||||
fieldErrors.name = body.name;
|
||||
}
|
||||
if (body.type) {
|
||||
fieldErrors.type = body.type;
|
||||
}
|
||||
if (body.config) {
|
||||
if (values.type === 'webhook') {
|
||||
fieldErrors.url = msg;
|
||||
} else {
|
||||
fieldErrors.script_path = msg;
|
||||
}
|
||||
}
|
||||
|
||||
const nonField = body.non_field_errors || body.detail;
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
form.setErrors(fieldErrors);
|
||||
}
|
||||
if (nonField) setApiError(nonField);
|
||||
if (!nonField && Object.keys(fieldErrors).length === 0) {
|
||||
setApiError(body);
|
||||
}
|
||||
} else {
|
||||
setApiError(error?.message || 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEvent = (event) => {
|
||||
setSelectedEvents((prev) =>
|
||||
prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event]
|
||||
);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} size="lg" onClose={handleClose} title="Connection">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{apiError ? (
|
||||
<Text c="red" size="sm">
|
||||
{apiError}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
<Select
|
||||
{...form.getInputProps('type')}
|
||||
key={form.key('type')}
|
||||
label="Connection Type"
|
||||
data={[
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'script', label: 'Custom Script' },
|
||||
]}
|
||||
/>
|
||||
{form.getValues().type === 'webhook' ? (
|
||||
<TextInput
|
||||
label="Webhook URL"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Script Path"
|
||||
{...form.getInputProps('script_path')}
|
||||
key={form.key('script_path')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text size="sm" weight={500} mb={5}>
|
||||
Event Triggers
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={3}>
|
||||
{EVENT_OPTIONS.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
checked={selectedEvents.includes(opt.value)}
|
||||
onChange={() => toggleEvent(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionForm;
|
||||
432
frontend/src/components/forms/CronBuilder.jsx
Normal file
432
frontend/src/components/forms/CronBuilder.jsx
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
/**
|
||||
* Cron Expression Builder Modal
|
||||
*
|
||||
* Provides an easy interface to build cron expressions with:
|
||||
* - Quick preset buttons for common schedules
|
||||
* - Simple hour/minute/day selectors
|
||||
* - Preview of next run times
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Select,
|
||||
NumberInput,
|
||||
Text,
|
||||
Badge,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
TextInput,
|
||||
Paper,
|
||||
Tabs,
|
||||
Code,
|
||||
} from '@mantine/core';
|
||||
import { Clock, Calendar } from 'lucide-react';
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
label: 'Every hour',
|
||||
value: '0 * * * *',
|
||||
description: 'At the start of every hour',
|
||||
},
|
||||
{
|
||||
label: 'Every 6 hours',
|
||||
value: '0 */6 * * *',
|
||||
description: 'Every 6 hours starting at midnight',
|
||||
},
|
||||
{
|
||||
label: 'Every 12 hours',
|
||||
value: '0 */12 * * *',
|
||||
description: 'Twice daily at midnight and noon',
|
||||
},
|
||||
{
|
||||
label: 'Daily at midnight',
|
||||
value: '0 0 * * *',
|
||||
description: 'Once per day at 12:00 AM',
|
||||
},
|
||||
{
|
||||
label: 'Daily at 3 AM',
|
||||
value: '0 3 * * *',
|
||||
description: 'Once per day at 3:00 AM',
|
||||
},
|
||||
{
|
||||
label: 'Daily at noon',
|
||||
value: '0 12 * * *',
|
||||
description: 'Once per day at 12:00 PM',
|
||||
},
|
||||
{
|
||||
label: 'Weekly (Sunday midnight)',
|
||||
value: '0 0 * * 0',
|
||||
description: 'Once per week on Sunday',
|
||||
},
|
||||
{
|
||||
label: 'Weekly (Monday 3 AM)',
|
||||
value: '0 3 * * 1',
|
||||
description: 'Once per week on Monday',
|
||||
},
|
||||
{
|
||||
label: 'Monthly (1st at 2:30 AM)',
|
||||
value: '30 2 1 * *',
|
||||
description: 'First day of each month',
|
||||
},
|
||||
];
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: '*', label: 'Every day' },
|
||||
{ value: '0', label: 'Sunday' },
|
||||
{ value: '1', label: 'Monday' },
|
||||
{ value: '2', label: 'Tuesday' },
|
||||
{ value: '3', label: 'Wednesday' },
|
||||
{ value: '4', label: 'Thursday' },
|
||||
{ value: '5', label: 'Friday' },
|
||||
{ value: '6', label: 'Saturday' },
|
||||
];
|
||||
|
||||
const FREQUENCY_OPTIONS = [
|
||||
{ value: 'hourly', label: 'Hourly' },
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
];
|
||||
|
||||
export default function CronBuilder({
|
||||
opened,
|
||||
onClose,
|
||||
onApply,
|
||||
currentValue = '',
|
||||
}) {
|
||||
const [mode, setMode] = useState('simple'); // 'simple' or 'advanced'
|
||||
const [frequency, setFrequency] = useState('daily');
|
||||
const [hour, setHour] = useState(3);
|
||||
const [minute, setMinute] = useState(0);
|
||||
const [dayOfWeek, setDayOfWeek] = useState('*');
|
||||
const [dayOfMonth, setDayOfMonth] = useState(1);
|
||||
const [generatedCron, setGeneratedCron] = useState('0 3 * * *');
|
||||
const [manualCron, setManualCron] = useState('* * * * *');
|
||||
|
||||
// Initialize manualCron from currentValue when modal opens
|
||||
useEffect(() => {
|
||||
if (opened && currentValue) {
|
||||
setManualCron(currentValue);
|
||||
}
|
||||
}, [opened, currentValue]);
|
||||
|
||||
// Update generated cron when inputs change
|
||||
useEffect(() => {
|
||||
let cron = '';
|
||||
switch (frequency) {
|
||||
case 'hourly':
|
||||
cron = `${minute} * * * *`;
|
||||
break;
|
||||
case 'daily':
|
||||
cron = `${minute} ${hour} * * *`;
|
||||
break;
|
||||
case 'weekly':
|
||||
cron = `${minute} ${hour} * * ${dayOfWeek === '*' ? '0' : dayOfWeek}`;
|
||||
break;
|
||||
case 'monthly':
|
||||
cron = `${minute} ${hour} ${dayOfMonth} * *`;
|
||||
break;
|
||||
}
|
||||
setGeneratedCron(cron);
|
||||
}, [frequency, hour, minute, dayOfWeek, dayOfMonth]);
|
||||
|
||||
const handlePresetClick = (cron) => {
|
||||
setGeneratedCron(cron);
|
||||
setManualCron(cron);
|
||||
|
||||
// Parse the cron expression and update form fields
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length === 5) {
|
||||
const [min, hr, day, _month, weekday] = parts;
|
||||
|
||||
setMinute(parseInt(min) || 0);
|
||||
|
||||
// Determine frequency based on pattern
|
||||
if (hr === '*') {
|
||||
setFrequency('hourly');
|
||||
} else if (day !== '*' && day !== '1') {
|
||||
// Has specific day of month
|
||||
setFrequency('monthly');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
setDayOfMonth(parseInt(day) || 1);
|
||||
} else if (weekday !== '*') {
|
||||
// Has specific day of week
|
||||
setFrequency('weekly');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
setDayOfWeek(weekday);
|
||||
} else if (day === '1') {
|
||||
// Monthly on 1st
|
||||
setFrequency('monthly');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
setDayOfMonth(1);
|
||||
} else {
|
||||
// Daily
|
||||
setFrequency('daily');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const cronToApply = mode === 'advanced' ? manualCron : generatedCron;
|
||||
onApply(cronToApply);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Cron Expression Builder"
|
||||
size="xl"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Tabs value={mode} onChange={setMode}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="simple">Simple</Tabs.Tab>
|
||||
<Tabs.Tab value="advanced">Advanced</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="simple" pt="md">
|
||||
<Stack gap="md">
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Quick Presets
|
||||
</Text>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
{PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.value}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => handlePresetClick(preset.value)}
|
||||
style={{
|
||||
height: '75px',
|
||||
padding: '8px',
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
inner: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
label: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={500} mb={2}>
|
||||
{preset.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{preset.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="dot"
|
||||
color="gray"
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
>
|
||||
{preset.value}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<Divider label="OR Build Custom" labelPosition="center" />
|
||||
|
||||
{/* Custom Builder */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Custom Schedule
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Select
|
||||
label="Frequency"
|
||||
data={FREQUENCY_OPTIONS}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
leftSection={<Calendar size={16} />}
|
||||
/>
|
||||
|
||||
{frequency !== 'hourly' && (
|
||||
<NumberInput
|
||||
label="Hour (0-23)"
|
||||
value={hour}
|
||||
onChange={setHour}
|
||||
min={0}
|
||||
max={23}
|
||||
leftSection={<Clock size={16} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label="Minute (0-59)"
|
||||
value={minute}
|
||||
onChange={setMinute}
|
||||
min={0}
|
||||
max={59}
|
||||
leftSection={<Clock size={16} />}
|
||||
/>
|
||||
|
||||
{frequency === 'weekly' && (
|
||||
<Select
|
||||
label="Day of Week"
|
||||
data={DAYS_OF_WEEK}
|
||||
value={dayOfWeek}
|
||||
onChange={setDayOfWeek}
|
||||
/>
|
||||
)}
|
||||
|
||||
{frequency === 'monthly' && (
|
||||
<NumberInput
|
||||
label="Day of Month (1-31)"
|
||||
value={dayOfMonth}
|
||||
onChange={setDayOfMonth}
|
||||
min={1}
|
||||
max={31}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="advanced" pt="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
Build advanced cron expressions with comma-separated values
|
||||
(e.g., <Code>2,4,16</Code>), ranges (e.g., <Code>9-17</Code>),
|
||||
or steps (e.g., <Code>*/15</Code>).
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput
|
||||
label="Minute (0-59)"
|
||||
placeholder="*, 0, */15, 0,15,30,45"
|
||||
value={manualCron.split(' ')[0] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[0] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Hour (0-23)"
|
||||
placeholder="*, 0, 9-17, */6, 2,4,16"
|
||||
value={manualCron.split(' ')[1] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[1] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Day of Month (1-31)"
|
||||
placeholder="*, 1, 1-15, */2, 1,15"
|
||||
value={manualCron.split(' ')[2] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[2] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Month (1-12)"
|
||||
placeholder="*, 1, 1-6, */3, 6,12"
|
||||
value={manualCron.split(' ')[3] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[3] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Day of Week (0-6, Sun-Sat)"
|
||||
placeholder="*, 0, 1-5, 0,6"
|
||||
value={manualCron.split(' ')[4] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[4] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Examples: <Code>0 4,10,16 * * *</Code> at 4 AM, 10 AM, and 4 PM
|
||||
• <Code>0 9-17 * * 1-5</Code> hourly 9 AM-5 PM Mon-Fri
|
||||
• <Code>*/15 * * * *</Code> every 15 minutes
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* Generated Expression */}
|
||||
<Paper withBorder p="md" bg="dark.6">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Expression:
|
||||
</Text>
|
||||
<Badge size="lg" variant="filled" color="blue">
|
||||
{mode === 'advanced' ? manualCron : generatedCron}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Actions */}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleApply}>Apply Expression</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,9 +16,11 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import ScheduleInput from './ScheduleInput';
|
||||
|
||||
const EPG = ({ epg = null, isOpen, onClose }) => {
|
||||
const [sourceType, setSourceType] = useState('xmltv');
|
||||
const [scheduleType, setScheduleType] = useState('interval');
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
|
|
@ -29,6 +31,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
api_key: '',
|
||||
is_active: true,
|
||||
refresh_interval: 24,
|
||||
cron_expression: '',
|
||||
priority: 0,
|
||||
},
|
||||
|
||||
|
|
@ -41,6 +44,17 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
|
||||
// Determine which schedule type is active based on field values
|
||||
const hasCronExpression =
|
||||
values.cron_expression && values.cron_expression.trim() !== '';
|
||||
|
||||
// Clear the field that isn't active based on actual field values
|
||||
if (hasCronExpression) {
|
||||
values.refresh_interval = 0;
|
||||
} else {
|
||||
values.cron_expression = '';
|
||||
}
|
||||
|
||||
if (epg?.id) {
|
||||
// Validate that we have a valid EPG object before updating
|
||||
if (!epg || typeof epg !== 'object' || !epg.id) {
|
||||
|
|
@ -70,13 +84,21 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
api_key: epg.api_key,
|
||||
is_active: epg.is_active,
|
||||
refresh_interval: epg.refresh_interval,
|
||||
cron_expression: epg.cron_expression || '',
|
||||
priority: epg.priority ?? 0,
|
||||
};
|
||||
form.setValues(values);
|
||||
setSourceType(epg.source_type);
|
||||
// Determine schedule type from existing data - check both fields
|
||||
setScheduleType(
|
||||
epg.cron_expression && epg.cron_expression.trim() !== ''
|
||||
? 'cron'
|
||||
: 'interval'
|
||||
);
|
||||
} else {
|
||||
form.reset();
|
||||
setSourceType('xmltv');
|
||||
setScheduleType('interval');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [epg]);
|
||||
|
|
@ -92,127 +114,136 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="EPG Source" size={700}>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
{/* Left Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
description="Unique identifier for this EPG source"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
|
||||
<NativeSelect
|
||||
id="source_type"
|
||||
name="source_type"
|
||||
label="Source Type"
|
||||
description="Format of the EPG data source"
|
||||
{...form.getInputProps('source_type')}
|
||||
key={form.key('source_type')}
|
||||
data={[
|
||||
{
|
||||
label: 'XMLTV',
|
||||
value: 'xmltv',
|
||||
},
|
||||
{
|
||||
label: 'Schedules Direct',
|
||||
value: 'schedules_direct',
|
||||
},
|
||||
]}
|
||||
onChange={(event) =>
|
||||
handleSourceTypeChange(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description="How often to refresh EPG data (0 to disable)"
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
min={0}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
{/* Right Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="URL"
|
||||
description="Direct URL to the XMLTV file or API endpoint"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
|
||||
{sourceType === 'schedules_direct' && (
|
||||
<>
|
||||
<Modal opened={isOpen} onClose={onClose} title="EPG Source" size={700}>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
{/* Left Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
description="API key for services that require authentication"
|
||||
{...form.getInputProps('api_key')}
|
||||
key={form.key('api_key')}
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
description="Unique identifier for this EPG source"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={999}
|
||||
label="Priority"
|
||||
description="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
|
||||
{...form.getInputProps('priority')}
|
||||
key={form.key('priority')}
|
||||
/>
|
||||
<NativeSelect
|
||||
id="source_type"
|
||||
name="source_type"
|
||||
label="Source Type"
|
||||
description="Format of the EPG data source"
|
||||
{...form.getInputProps('source_type')}
|
||||
key={form.key('source_type')}
|
||||
data={[
|
||||
{
|
||||
label: 'XMLTV',
|
||||
value: 'xmltv',
|
||||
},
|
||||
{
|
||||
label: 'Schedules Direct',
|
||||
value: 'schedules_direct',
|
||||
},
|
||||
]}
|
||||
onChange={(event) =>
|
||||
handleSourceTypeChange(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Put checkbox at the same level as Refresh Interval */}
|
||||
<Box style={{ marginTop: 0 }}>
|
||||
<Text size="sm" fw={500} mb={3}>
|
||||
Status
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={12}>
|
||||
When enabled, this EPG source will auto update.
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '30px',
|
||||
marginTop: '-4px',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
label="Enable this EPG source"
|
||||
{...form.getInputProps('is_active', { type: 'checkbox' })}
|
||||
key={form.key('is_active')}
|
||||
<ScheduleInput
|
||||
scheduleType={scheduleType}
|
||||
onScheduleTypeChange={setScheduleType}
|
||||
intervalValue={form.getValues().refresh_interval}
|
||||
onIntervalChange={(v) =>
|
||||
form.setFieldValue('refresh_interval', v)
|
||||
}
|
||||
cronValue={form.getValues().cron_expression}
|
||||
onCronChange={(expr) =>
|
||||
form.setFieldValue('cron_expression', expr)
|
||||
}
|
||||
intervalLabel="Refresh Interval (hours)"
|
||||
intervalDescription="How often to refresh EPG data (0 to disable)"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
{/* Right Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="URL"
|
||||
description="Direct URL to the XMLTV file or API endpoint"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
|
||||
{sourceType === 'schedules_direct' && (
|
||||
<TextInput
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
description="API key for services that require authentication"
|
||||
{...form.getInputProps('api_key')}
|
||||
key={form.key('api_key')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={999}
|
||||
label="Priority"
|
||||
description="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
|
||||
{...form.getInputProps('priority')}
|
||||
key={form.key('priority')}
|
||||
/>
|
||||
|
||||
{/* Put checkbox at the same level as Refresh Interval */}
|
||||
<Box style={{ marginTop: 0 }}>
|
||||
<Text size="sm" fw={500} mb={3}>
|
||||
Status
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={12}>
|
||||
When enabled, this EPG source will auto update.
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '30px',
|
||||
marginTop: '-4px',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
label="Enable this EPG source"
|
||||
{...form.getInputProps('is_active', { type: 'checkbox' })}
|
||||
key={form.key('is_active')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Full Width Section */}
|
||||
<Box mt="md">
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="end" mt="xl">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="filled" disabled={form.submitting}>
|
||||
{epg?.id ? 'Update' : 'Create'} EPG Source
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Full Width Section */}
|
||||
<Box mt="md">
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="end" mt="xl">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="filled" disabled={form.submitting}>
|
||||
{epg?.id ? 'Update' : 'Create'} EPG Source
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -314,17 +314,119 @@ const LiveGroupFilter = ({
|
|||
|
||||
{group.auto_channel_sync && group.enabled && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Start Channel #"
|
||||
value={group.auto_sync_channel_start}
|
||||
onChange={(value) =>
|
||||
updateChannelStart(group.channel_group, value)
|
||||
<Tooltip
|
||||
label={
|
||||
<div>
|
||||
<div>
|
||||
<strong>Fixed:</strong> Start at a specific number
|
||||
and increment
|
||||
</div>
|
||||
<div>
|
||||
<strong>Provider:</strong> Use channel numbers
|
||||
from the M3U source
|
||||
</div>
|
||||
<div>
|
||||
<strong>Next Available:</strong> Auto-assign
|
||||
starting from 1, skipping used numbers
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
min={1}
|
||||
step={1}
|
||||
size="xs"
|
||||
precision={1}
|
||||
/>
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<Select
|
||||
label="Channel Numbering Mode"
|
||||
placeholder="Select mode..."
|
||||
value={
|
||||
group.custom_properties?.channel_numbering_mode ||
|
||||
'fixed'
|
||||
}
|
||||
onChange={(value) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (
|
||||
state.channel_group === group.channel_group
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
custom_properties: {
|
||||
...state.custom_properties,
|
||||
channel_numbering_mode: value || 'fixed',
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
value: 'fixed',
|
||||
label: 'Fixed Start Number',
|
||||
},
|
||||
{
|
||||
value: 'provider',
|
||||
label: 'Use Provider Number',
|
||||
},
|
||||
{
|
||||
value: 'next_available',
|
||||
label: 'Next Available',
|
||||
},
|
||||
]}
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{(!group.custom_properties?.channel_numbering_mode ||
|
||||
group.custom_properties?.channel_numbering_mode ===
|
||||
'fixed') && (
|
||||
<NumberInput
|
||||
label="Start Channel #"
|
||||
value={group.auto_sync_channel_start}
|
||||
onChange={(value) =>
|
||||
updateChannelStart(group.channel_group, value)
|
||||
}
|
||||
min={1}
|
||||
step={1}
|
||||
size="xs"
|
||||
precision={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{group.custom_properties?.channel_numbering_mode ===
|
||||
'provider' && (
|
||||
<NumberInput
|
||||
label="Fallback Channel # (if provider # missing)"
|
||||
value={
|
||||
group.custom_properties
|
||||
?.channel_numbering_fallback || 1
|
||||
}
|
||||
onChange={(value) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (
|
||||
state.channel_group === group.channel_group
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
custom_properties: {
|
||||
...state.custom_properties,
|
||||
channel_numbering_fallback: value || 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
}}
|
||||
min={1}
|
||||
step={1}
|
||||
size="xs"
|
||||
precision={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Auto Channel Sync Options Multi-Select */}
|
||||
<MultiSelect
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { isNotEmpty, useForm } from '@mantine/form';
|
|||
import useEPGsStore from '../../store/epgs';
|
||||
import useVODStore from '../../store/useVODStore';
|
||||
import M3UFilters from './M3UFilters';
|
||||
import ScheduleInput from './ScheduleInput';
|
||||
|
||||
const M3U = ({
|
||||
m3uAccount = null,
|
||||
|
|
@ -49,6 +50,7 @@ const M3U = ({
|
|||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState('');
|
||||
const [showCredentialFields, setShowCredentialFields] = useState(false);
|
||||
const [scheduleType, setScheduleType] = useState('interval');
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
|
|
@ -59,6 +61,7 @@ const M3U = ({
|
|||
is_active: true,
|
||||
max_streams: 0,
|
||||
refresh_interval: 24,
|
||||
cron_expression: '',
|
||||
account_type: 'XC',
|
||||
create_epg: false,
|
||||
username: '',
|
||||
|
|
@ -71,12 +74,10 @@ const M3U = ({
|
|||
validate: {
|
||||
name: isNotEmpty('Please select a name'),
|
||||
user_agent: isNotEmpty('Please select a user-agent'),
|
||||
refresh_interval: isNotEmpty('Please specify a refresh interval'),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log(m3uAccount);
|
||||
if (m3uAccount) {
|
||||
setPlaylist(m3uAccount);
|
||||
form.setValues({
|
||||
|
|
@ -86,6 +87,7 @@ const M3U = ({
|
|||
user_agent: m3uAccount.user_agent ? `${m3uAccount.user_agent}` : '0',
|
||||
is_active: m3uAccount.is_active,
|
||||
refresh_interval: m3uAccount.refresh_interval,
|
||||
cron_expression: m3uAccount.cron_expression || '',
|
||||
account_type: m3uAccount.account_type,
|
||||
username: m3uAccount.username ?? '',
|
||||
password: '',
|
||||
|
|
@ -101,6 +103,13 @@ const M3U = ({
|
|||
enable_vod: m3uAccount.enable_vod || false,
|
||||
});
|
||||
|
||||
// Determine schedule type from existing data
|
||||
setScheduleType(
|
||||
m3uAccount.cron_expression && m3uAccount.cron_expression.trim() !== ''
|
||||
? 'cron'
|
||||
: 'interval'
|
||||
);
|
||||
|
||||
if (m3uAccount.account_type == 'XC') {
|
||||
setShowCredentialFields(true);
|
||||
} else {
|
||||
|
|
@ -109,6 +118,7 @@ const M3U = ({
|
|||
} else {
|
||||
setPlaylist(null);
|
||||
form.reset();
|
||||
setScheduleType('interval');
|
||||
}
|
||||
}, [m3uAccount]);
|
||||
|
||||
|
|
@ -121,6 +131,17 @@ const M3U = ({
|
|||
const onSubmit = async () => {
|
||||
const { create_epg, ...values } = form.getValues();
|
||||
|
||||
// Determine which schedule type is active based on field values
|
||||
const hasCronExpression =
|
||||
values.cron_expression && values.cron_expression.trim() !== '';
|
||||
|
||||
// Clear the field that isn't active based on actual field values
|
||||
if (hasCronExpression) {
|
||||
values.refresh_interval = 0;
|
||||
} else {
|
||||
values.cron_expression = '';
|
||||
}
|
||||
|
||||
if (values.account_type == 'XC' && values.password == '') {
|
||||
// If account XC and no password input, assuming no password change
|
||||
// from previously stored value.
|
||||
|
|
@ -377,17 +398,25 @@ const M3U = ({
|
|||
)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description={
|
||||
<ScheduleInput
|
||||
scheduleType={scheduleType}
|
||||
onScheduleTypeChange={setScheduleType}
|
||||
intervalValue={form.getValues().refresh_interval}
|
||||
onIntervalChange={(v) =>
|
||||
form.setFieldValue('refresh_interval', v)
|
||||
}
|
||||
cronValue={form.getValues().cron_expression}
|
||||
onCronChange={(expr) =>
|
||||
form.setFieldValue('cron_expression', expr)
|
||||
}
|
||||
intervalLabel="Refresh Interval (hours)"
|
||||
intervalDescription={
|
||||
<>
|
||||
How often to automatically refresh M3U data
|
||||
<br />
|
||||
(0 to disable automatic refreshes)
|
||||
</>
|
||||
}
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
|
|
|
|||
229
frontend/src/components/forms/ScheduleInput.jsx
Normal file
229
frontend/src/components/forms/ScheduleInput.jsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Reusable Schedule Input
|
||||
*
|
||||
* Shows the active scheduling mode with a subtle text link to switch.
|
||||
* Interval mode is the default; a small "Use cron schedule" link beneath
|
||||
* toggles to cron mode, and vice-versa.
|
||||
*
|
||||
* For M3U / EPG (default interval NumberInput):
|
||||
* <ScheduleInput
|
||||
* scheduleType={scheduleType}
|
||||
* onScheduleTypeChange={setScheduleType}
|
||||
* intervalValue={form.getValues().refresh_interval}
|
||||
* onIntervalChange={(v) => form.setFieldValue('refresh_interval', v)}
|
||||
* cronValue={form.getValues().cron_expression}
|
||||
* onCronChange={(v) => form.setFieldValue('cron_expression', v)}
|
||||
* />
|
||||
*
|
||||
* For Backups (custom simple-mode UI via children):
|
||||
* <ScheduleInput
|
||||
* scheduleType={scheduleType}
|
||||
* onScheduleTypeChange={setScheduleType}
|
||||
* cronValue={schedule.cron_expression}
|
||||
* onCronChange={(v) => handleScheduleChange('cron_expression', v)}
|
||||
* switchToCronLabel="Use custom cron schedule"
|
||||
* switchToIntervalLabel="Use simple schedule"
|
||||
* >
|
||||
* ...frequency / time / day selectors...
|
||||
* </ScheduleInput>
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Anchor,
|
||||
Stack,
|
||||
Text,
|
||||
Code,
|
||||
Popover,
|
||||
ActionIcon,
|
||||
Group,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
import { validateCronExpression } from '../../utils/cronUtils';
|
||||
import CronBuilder from './CronBuilder';
|
||||
|
||||
export default function ScheduleInput({
|
||||
// Schedule type
|
||||
scheduleType = 'interval',
|
||||
onScheduleTypeChange,
|
||||
|
||||
// Cron
|
||||
cronValue = '',
|
||||
onCronChange,
|
||||
|
||||
// Default interval input (used when children not provided)
|
||||
intervalValue = 0,
|
||||
onIntervalChange,
|
||||
intervalLabel = 'Refresh Interval (hours)',
|
||||
intervalDescription = 'How often to refresh (0 to disable)',
|
||||
min = 0,
|
||||
|
||||
// Custom simple-mode content (replaces the default NumberInput)
|
||||
children,
|
||||
|
||||
// Link text for toggling
|
||||
switchToCronLabel = 'Use cron schedule',
|
||||
switchToIntervalLabel = 'Use interval schedule',
|
||||
|
||||
disabled = false,
|
||||
}) {
|
||||
const [cronError, setCronError] = useState(null);
|
||||
const [builderOpened, setBuilderOpened] = useState(false);
|
||||
|
||||
// Validate cron whenever it changes
|
||||
useEffect(() => {
|
||||
if (scheduleType === 'cron' && cronValue) {
|
||||
const v = validateCronExpression(cronValue);
|
||||
setCronError(v.valid ? null : v.error);
|
||||
} else {
|
||||
setCronError(null);
|
||||
}
|
||||
}, [scheduleType, cronValue]);
|
||||
|
||||
const switchToCron = (e) => {
|
||||
e.preventDefault();
|
||||
onScheduleTypeChange('cron');
|
||||
};
|
||||
|
||||
const switchToInterval = (e) => {
|
||||
e.preventDefault();
|
||||
onScheduleTypeChange('interval');
|
||||
onCronChange('');
|
||||
setCronError(null);
|
||||
};
|
||||
|
||||
const handleCronChange = (val) => {
|
||||
onCronChange(val);
|
||||
if (val) {
|
||||
const v = validateCronExpression(val);
|
||||
setCronError(v.valid ? null : v.error);
|
||||
} else {
|
||||
setCronError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuilderApply = (cron) => {
|
||||
handleCronChange(cron);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{scheduleType === 'cron' ? (
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
Cron Expression
|
||||
<Popover width={320} position="top" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<ActionIcon variant="subtle" size="xs" color="gray">
|
||||
<Info size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="sm">
|
||||
<Text size="xs" fw={600} mb="xs" c="dimmed">
|
||||
COMMON EXAMPLES
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
Every day at 3 AM:
|
||||
</Text>
|
||||
<Code size="xs">0 3 * * *</Code>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
At 4 AM, 10 AM, 4 PM:
|
||||
</Text>
|
||||
<Code size="xs">0 4,10,16 * * *</Code>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
Sundays at 2 AM:
|
||||
</Text>
|
||||
<Code size="xs">0 2 * * 0</Code>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
1st of month at 2:30 PM:
|
||||
</Text>
|
||||
<Code size="xs">30 14 1 * *</Code>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
}
|
||||
placeholder="0 3 * * *"
|
||||
description="minute hour day month weekday"
|
||||
value={cronValue}
|
||||
onChange={(e) => handleCronChange(e.currentTarget.value)}
|
||||
error={cronError}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!disabled && (
|
||||
<Group gap="sm">
|
||||
<Anchor size="xs" onClick={switchToInterval}>
|
||||
{switchToIntervalLabel}
|
||||
</Anchor>
|
||||
<Anchor size="xs" onClick={() => setBuilderOpened(true)}>
|
||||
Open Cron Builder
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
<CronBuilder
|
||||
opened={builderOpened}
|
||||
onClose={() => setBuilderOpened(false)}
|
||||
onApply={handleBuilderApply}
|
||||
currentValue={cronValue}
|
||||
/>
|
||||
</Stack>
|
||||
) : children ? (
|
||||
<Stack gap="xs">
|
||||
{children}
|
||||
{!disabled && (
|
||||
<Anchor size="xs" onClick={switchToCron}>
|
||||
{switchToCronLabel}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<NumberInput
|
||||
label={intervalLabel}
|
||||
description={intervalDescription}
|
||||
value={intervalValue}
|
||||
onChange={onIntervalChange}
|
||||
min={min}
|
||||
disabled={disabled}
|
||||
suffix=" hours"
|
||||
/>
|
||||
{!disabled && (
|
||||
<Anchor size="xs" onClick={switchToCron}>
|
||||
{switchToCronLabel}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue