mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-30 05:20:12 +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
|
|
@ -14,6 +14,8 @@ import Stats from './pages/Stats';
|
|||
import DVR from './pages/DVR';
|
||||
import Settings from './pages/Settings';
|
||||
import PluginsPage from './pages/Plugins';
|
||||
import ConnectPage from './pages/Connect';
|
||||
import ConnectLogsPage from './pages/ConnectLogs';
|
||||
import Users from './pages/Users';
|
||||
import LogosPage from './pages/Logos';
|
||||
import VODsPage from './pages/VODs';
|
||||
|
|
@ -152,6 +154,11 @@ const App = () => {
|
|||
<Route path="/dvr" element={<DVR />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/plugins" element={<PluginsPage />} />
|
||||
<Route path="/connect" element={<ConnectPage />} />
|
||||
<Route
|
||||
path="/connect/logs"
|
||||
element={<ConnectLogsPage />}
|
||||
/>
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/logos" element={<LogosPage />} />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { notifications } from '@mantine/notifications';
|
|||
import useChannelsTableStore from './store/channelsTable';
|
||||
import useStreamsTableStore from './store/streamsTable';
|
||||
import useUsersStore from './store/users';
|
||||
import useConnectStore from './store/connect';
|
||||
|
||||
// If needed, you can set a base host or keep it empty if relative requests
|
||||
const host = import.meta.env.DEV
|
||||
|
|
@ -178,9 +179,34 @@ export default class API {
|
|||
|
||||
static async getChannels() {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/channels/`);
|
||||
// Paginate through channels to avoid heavy single response
|
||||
const pageSize = 200;
|
||||
let page = 1;
|
||||
let allChannels = [];
|
||||
|
||||
return response;
|
||||
while (true) {
|
||||
const data = await request(
|
||||
`${host}/api/channels/channels/?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
|
||||
// Backward compatibility: if endpoint returns an array (legacy), just return it
|
||||
if (Array.isArray(data)) {
|
||||
allChannels = data;
|
||||
break;
|
||||
}
|
||||
|
||||
const results = Array.isArray(data?.results) ? data.results : [];
|
||||
allChannels = allChannels.concat(results);
|
||||
|
||||
const hasMore = Boolean(data?.next);
|
||||
if (!hasMore || results.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return allChannels;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve channels', e);
|
||||
}
|
||||
|
|
@ -3133,4 +3159,124 @@ export default class API {
|
|||
errorNotification('Failed to dismiss all notifications', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getConnectIntegrations() {
|
||||
try {
|
||||
return await request(`${host}/api/connect/integrations/`);
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch connect integrations', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async createConnectIntegration(values) {
|
||||
try {
|
||||
const response = await request(`${host}/api/connect/integrations/`, {
|
||||
method: 'POST',
|
||||
body: values,
|
||||
});
|
||||
|
||||
useConnectStore.getState().addIntegration(response);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to create integration', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateConnectIntegration(id, values) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/connect/integrations/${id}/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: values,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.id) {
|
||||
useConnectStore.getState().updateIntegration(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update integration', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteConnectIntegration(id) {
|
||||
try {
|
||||
await request(`${host}/api/connect/integrations/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
useConnectStore.getState().removeIntegration(id);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete integration', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async createConnectSubscription(values) {
|
||||
try {
|
||||
await request(`${host}/api/connect/subscriptions/`, {
|
||||
method: 'POST',
|
||||
body: values,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to create subscription', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async listConnectSubscriptions(integrationId) {
|
||||
try {
|
||||
return await request(
|
||||
`${host}/api/connect/integrations/${integrationId}/subscriptions/`
|
||||
);
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch subscriptions', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async setConnectSubscriptions(integrationId, subscriptions) {
|
||||
// subscriptions: [{ event, enabled, payload_template }]
|
||||
console.log(subscriptions);
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/connect/integrations/${integrationId}/subscriptions/set/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: subscriptions,
|
||||
}
|
||||
);
|
||||
|
||||
useConnectStore
|
||||
.getState()
|
||||
.updateIntegrationSubscriptions(integrationId, response);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to set subscriptions', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async getConnectLogs(params = {}) {
|
||||
try {
|
||||
const search = new URLSearchParams();
|
||||
if (params.page) search.set('page', params.page);
|
||||
if (params.page_size) search.set('page_size', params.page_size);
|
||||
if (params.type) search.set('type', params.type);
|
||||
if (params.integration) search.set('integration', params.integration);
|
||||
|
||||
return await request(
|
||||
`${host}/api/connect/logs/${search.toString() ? `?${search.toString()}` : ''}`
|
||||
);
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch connect logs', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ import {
|
|||
LogOut,
|
||||
User,
|
||||
FileImage,
|
||||
Webhook,
|
||||
Logs,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MonitorCog,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar,
|
||||
|
|
@ -27,6 +32,7 @@ import {
|
|||
TextInput,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
ScrollArea,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import logo from '../images/logo.png';
|
||||
|
|
@ -70,6 +76,74 @@ const NavLink = ({ item, isActive, collapsed }) => {
|
|||
);
|
||||
};
|
||||
|
||||
function NavGroup({ label, icon, paths, location, collapsed }) {
|
||||
const [open, setOpen] = useState(() =>
|
||||
location.pathname.startsWith('/connect')
|
||||
);
|
||||
|
||||
const parentActive = paths
|
||||
.map((path) => path.path)
|
||||
.includes(location.pathname);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{ width: '100%', paddingRight: 2 }}
|
||||
className={open ? 'navgroup-open' : ''}
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{icon}
|
||||
{!collapsed && (
|
||||
<Group justify="space-between" style={{ width: '100%' }}>
|
||||
<Text
|
||||
sx={{
|
||||
opacity: open ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: open ? 0 : 150,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<Box alignItems="center" style={{ display: 'flex' }}>
|
||||
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
|
||||
{open && (
|
||||
<Box style={{ paddingTop: 10 }}>
|
||||
<Stack gap="xs" pl={open ? 0 : 'lg'}>
|
||||
{paths.map((child) => {
|
||||
const active = location.pathname === child.path;
|
||||
return (
|
||||
<Box
|
||||
style={{ paddingLeft: collapsed ? 0 : 35 }}
|
||||
key={child.path}
|
||||
>
|
||||
<NavLink
|
||||
key={child.path}
|
||||
item={child}
|
||||
isActive={active}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
||||
const location = useLocation();
|
||||
|
||||
|
|
@ -111,19 +185,41 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
|
||||
{ label: 'Plugins', icon: <PlugZap size={20} />, path: '/plugins' },
|
||||
{
|
||||
label: 'Users',
|
||||
icon: <User size={20} />,
|
||||
path: '/users',
|
||||
},
|
||||
{
|
||||
label: 'Logo Manager',
|
||||
icon: <FileImage size={20} />,
|
||||
path: '/logos',
|
||||
label: 'Connect',
|
||||
icon: <Webhook size={20} />,
|
||||
paths: [
|
||||
{
|
||||
label: 'Connections',
|
||||
icon: <Webhook size={20} />,
|
||||
path: '/connect',
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
icon: <Logs size={20} />,
|
||||
path: '/connect/logs',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: <LucideSettings size={20} />,
|
||||
path: '/settings',
|
||||
paths: [
|
||||
{
|
||||
label: 'Users',
|
||||
icon: <User size={20} />,
|
||||
path: '/users',
|
||||
},
|
||||
{
|
||||
label: 'Logo Manager',
|
||||
icon: <FileImage size={20} />,
|
||||
path: '/logos',
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
icon: <MonitorCog size={20} />,
|
||||
path: '/settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: [
|
||||
|
|
@ -205,20 +301,44 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
</Group>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<Stack gap="xs" mt="lg">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
<ScrollArea h="100%" type="scroll" scrollbars="y">
|
||||
<Stack
|
||||
gap="xs"
|
||||
mt="lg"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
{navItems.map((item) => {
|
||||
if (item.paths) {
|
||||
return (
|
||||
<NavGroup
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
paths={item.paths}
|
||||
location={location}
|
||||
collapsed={collapsed}
|
||||
icon={item.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Profile Section */}
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import ConfirmationDialog from '../ConfirmationDialog';
|
|||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import { CustomTable, useTable } from '../tables/CustomTable';
|
||||
import { validateCronExpression } from '../../utils/cronUtils';
|
||||
import ScheduleInput from '../forms/ScheduleInput';
|
||||
|
||||
const RowActions = ({
|
||||
row,
|
||||
|
|
@ -113,95 +115,6 @@ function getDefaultTimeZone() {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate cron expression
|
||||
function validateCronExpression(expression) {
|
||||
if (!expression || expression.trim() === '') {
|
||||
return { valid: false, error: 'Cron expression is required' };
|
||||
}
|
||||
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'Cron expression must have exactly 5 parts: minute hour day month weekday',
|
||||
};
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
|
||||
// Validate each part (allowing *, */N steps, ranges, lists, steps)
|
||||
// Supports: *, */2, 5, 1-5, 1-5/2, 1,3,5, etc.
|
||||
const cronPartRegex =
|
||||
/^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/;
|
||||
|
||||
if (!cronPartRegex.test(minute)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid minute field (0-59, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(hour)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid hour field (0-23, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(dayOfMonth)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid day field (1-31, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(month)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid month field (1-12, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(dayOfWeek)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid weekday field (0-6, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
|
||||
// Additional range validation for numeric values
|
||||
const validateRange = (value, min, max, name) => {
|
||||
// Skip if it's * or contains special characters
|
||||
if (
|
||||
value === '*' ||
|
||||
value.includes('/') ||
|
||||
value.includes('-') ||
|
||||
value.includes(',')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < min || num > max) {
|
||||
return `${name} must be between ${min} and ${max}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const minuteError = validateRange(minute, 0, 59, 'Minute');
|
||||
if (minuteError) return { valid: false, error: minuteError };
|
||||
|
||||
const hourError = validateRange(hour, 0, 23, 'Hour');
|
||||
if (hourError) return { valid: false, error: hourError };
|
||||
|
||||
const dayError = validateRange(dayOfMonth, 1, 31, 'Day');
|
||||
if (dayError) return { valid: false, error: dayError };
|
||||
|
||||
const monthError = validateRange(month, 1, 12, 'Month');
|
||||
if (monthError) return { valid: false, error: monthError };
|
||||
|
||||
const weekdayError = validateRange(dayOfWeek, 0, 6, 'Weekday');
|
||||
if (weekdayError) return { valid: false, error: weekdayError };
|
||||
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: '0', label: 'Sunday' },
|
||||
{ value: '1', label: 'Monday' },
|
||||
|
|
@ -262,8 +175,7 @@ export default function BackupManager() {
|
|||
const [scheduleLoading, setScheduleLoading] = useState(false);
|
||||
const [scheduleSaving, setScheduleSaving] = useState(false);
|
||||
const [scheduleChanged, setScheduleChanged] = useState(false);
|
||||
const [advancedMode, setAdvancedMode] = useState(false);
|
||||
const [cronError, setCronError] = useState(null);
|
||||
const [scheduleType, setScheduleType] = useState('interval');
|
||||
|
||||
// For 12-hour display mode
|
||||
const [displayTime, setDisplayTime] = useState('3:00');
|
||||
|
|
@ -373,12 +285,8 @@ export default function BackupManager() {
|
|||
try {
|
||||
const settings = await API.getBackupSchedule();
|
||||
|
||||
// Check if using cron expression (advanced mode)
|
||||
if (settings.cron_expression) {
|
||||
setAdvancedMode(true);
|
||||
}
|
||||
|
||||
setSchedule(settings);
|
||||
setScheduleType(settings.cron_expression ? 'cron' : 'interval');
|
||||
|
||||
// Initialize 12-hour display values
|
||||
const { time, period } = to12Hour(settings.time);
|
||||
|
|
@ -398,25 +306,9 @@ export default function BackupManager() {
|
|||
loadSchedule();
|
||||
}, []);
|
||||
|
||||
// Validate cron expression when switching to advanced mode
|
||||
useEffect(() => {
|
||||
if (advancedMode && schedule.cron_expression) {
|
||||
const validation = validateCronExpression(schedule.cron_expression);
|
||||
setCronError(validation.valid ? null : validation.error);
|
||||
} else {
|
||||
setCronError(null);
|
||||
}
|
||||
}, [advancedMode, schedule.cron_expression]);
|
||||
|
||||
const handleScheduleChange = (field, value) => {
|
||||
setSchedule((prev) => ({ ...prev, [field]: value }));
|
||||
setScheduleChanged(true);
|
||||
|
||||
// Validate cron expression if in advanced mode
|
||||
if (field === 'cron_expression' && advancedMode) {
|
||||
const validation = validateCronExpression(value);
|
||||
setCronError(validation.valid ? null : validation.error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle time changes in 12-hour mode
|
||||
|
|
@ -442,9 +334,11 @@ export default function BackupManager() {
|
|||
const handleSaveSchedule = async () => {
|
||||
setScheduleSaving(true);
|
||||
try {
|
||||
const scheduleToSave = advancedMode
|
||||
? schedule
|
||||
: { ...schedule, cron_expression: '' };
|
||||
// Clear cron_expression if not in cron mode
|
||||
const scheduleToSave =
|
||||
scheduleType === 'cron'
|
||||
? schedule
|
||||
: { ...schedule, cron_expression: '' };
|
||||
|
||||
const updated = await API.updateBackupSchedule(scheduleToSave);
|
||||
setSchedule(updated);
|
||||
|
|
@ -603,207 +497,161 @@ export default function BackupManager() {
|
|||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
Advanced (Cron Expression)
|
||||
</Text>
|
||||
<Switch
|
||||
checked={advancedMode}
|
||||
onChange={(e) => setAdvancedMode(e.currentTarget.checked)}
|
||||
label={advancedMode ? 'Enabled' : 'Disabled'}
|
||||
disabled={!schedule.enabled}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<ScheduleInput
|
||||
scheduleType={scheduleType}
|
||||
onScheduleTypeChange={(type) => {
|
||||
setScheduleType(type);
|
||||
if (type !== 'cron') {
|
||||
handleScheduleChange('cron_expression', '');
|
||||
}
|
||||
}}
|
||||
cronValue={schedule.cron_expression}
|
||||
onCronChange={(expr) => handleScheduleChange('cron_expression', expr)}
|
||||
disabled={!schedule.enabled}
|
||||
switchToCronLabel="Use custom cron schedule"
|
||||
switchToIntervalLabel="Use simple schedule"
|
||||
>
|
||||
{/* Simple mode: frequency / time / day selectors */}
|
||||
<Stack gap="sm">
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
label="Frequency"
|
||||
value={schedule.frequency}
|
||||
onChange={(value) => handleScheduleChange('frequency', value)}
|
||||
data={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
{schedule.frequency === 'weekly' && (
|
||||
<Select
|
||||
label="Day"
|
||||
value={String(schedule.day_of_week)}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('day_of_week', parseInt(value, 10))
|
||||
}
|
||||
data={DAYS_OF_WEEK}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
)}
|
||||
{is12Hour ? (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={displayTime ? displayTime.split(':')[0] : '12'}
|
||||
onChange={(value) => {
|
||||
const minute = displayTime
|
||||
? displayTime.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange12h(`${value}:${minute}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 12 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: String(i + 1),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={displayTime ? displayTime.split(':')[1] : '00'}
|
||||
onChange={(value) => {
|
||||
const hour = displayTime
|
||||
? displayTime.split(':')[0]
|
||||
: '12';
|
||||
handleTimeChange12h(`${hour}:${value}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Period"
|
||||
value={timePeriod}
|
||||
onChange={(value) => handleTimeChange12h(null, value)}
|
||||
data={[
|
||||
{ value: 'AM', label: 'AM' },
|
||||
{ value: 'PM', label: 'PM' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={schedule.time ? schedule.time.split(':')[0] : '00'}
|
||||
onChange={(value) => {
|
||||
const minute = schedule.time
|
||||
? schedule.time.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange24h(`${value}:${minute}`);
|
||||
}}
|
||||
data={Array.from({ length: 24 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={schedule.time ? schedule.time.split(':')[1] : '00'}
|
||||
onChange={(value) => {
|
||||
const hour = schedule.time
|
||||
? schedule.time.split(':')[0]
|
||||
: '00';
|
||||
handleTimeChange24h(`${hour}:${value}`);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</ScheduleInput>
|
||||
|
||||
{scheduleLoading ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<>
|
||||
{advancedMode ? (
|
||||
<>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Cron Expression"
|
||||
value={schedule.cron_expression}
|
||||
onChange={(e) =>
|
||||
handleScheduleChange(
|
||||
'cron_expression',
|
||||
e.currentTarget.value
|
||||
)
|
||||
}
|
||||
placeholder="0 3 * * *"
|
||||
description="Format: minute hour day month weekday (e.g., '0 3 * * *' = 3:00 AM daily)"
|
||||
disabled={!schedule.enabled}
|
||||
error={cronError}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
Examples: <br />• <code>0 3 * * *</code> - Every day at 3:00
|
||||
AM
|
||||
<br />• <code>0 2 * * 0</code> - Every Sunday at 2:00 AM
|
||||
<br />• <code>0 */6 * * *</code> - Every 6 hours
|
||||
<br />• <code>30 14 1 * *</code> - 1st of every month at
|
||||
2:30 PM
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group grow align="flex-end">
|
||||
<NumberInput
|
||||
label="Retention"
|
||||
description="0 = keep all"
|
||||
value={schedule.retention_count}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('retention_count', value || 0)
|
||||
}
|
||||
min={0}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveSchedule}
|
||||
loading={scheduleSaving}
|
||||
disabled={!scheduleChanged || (advancedMode && cronError)}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
label="Frequency"
|
||||
value={schedule.frequency}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('frequency', value)
|
||||
}
|
||||
data={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
{schedule.frequency === 'weekly' && (
|
||||
<Select
|
||||
label="Day"
|
||||
value={String(schedule.day_of_week)}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('day_of_week', parseInt(value, 10))
|
||||
}
|
||||
data={DAYS_OF_WEEK}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
)}
|
||||
{is12Hour ? (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={displayTime ? displayTime.split(':')[0] : '12'}
|
||||
onChange={(value) => {
|
||||
const minute = displayTime
|
||||
? displayTime.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange12h(`${value}:${minute}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 12 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: String(i + 1),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={displayTime ? displayTime.split(':')[1] : '00'}
|
||||
onChange={(value) => {
|
||||
const hour = displayTime
|
||||
? displayTime.split(':')[0]
|
||||
: '12';
|
||||
handleTimeChange12h(`${hour}:${value}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Period"
|
||||
value={timePeriod}
|
||||
onChange={(value) => handleTimeChange12h(null, value)}
|
||||
data={[
|
||||
{ value: 'AM', label: 'AM' },
|
||||
{ value: 'PM', label: 'PM' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={
|
||||
schedule.time ? schedule.time.split(':')[0] : '00'
|
||||
}
|
||||
onChange={(value) => {
|
||||
const minute = schedule.time
|
||||
? schedule.time.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange24h(`${value}:${minute}`);
|
||||
}}
|
||||
data={Array.from({ length: 24 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={
|
||||
schedule.time ? schedule.time.split(':')[1] : '00'
|
||||
}
|
||||
onChange={(value) => {
|
||||
const hour = schedule.time
|
||||
? schedule.time.split(':')[0]
|
||||
: '00';
|
||||
handleTimeChange24h(`${hour}:${value}`);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group grow align="flex-end" gap="xs">
|
||||
<NumberInput
|
||||
label="Retention"
|
||||
description="0 = keep all"
|
||||
value={schedule.retention_count}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('retention_count', value || 0)
|
||||
}
|
||||
min={0}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveSchedule}
|
||||
loading={scheduleSaving}
|
||||
disabled={!scheduleChanged}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
<Group grow align="flex-end" gap="xs">
|
||||
<NumberInput
|
||||
label="Retention"
|
||||
description="0 = keep all"
|
||||
value={schedule.retention_count}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('retention_count', value || 0)
|
||||
}
|
||||
min={0}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveSchedule}
|
||||
loading={scheduleSaving}
|
||||
disabled={
|
||||
!scheduleChanged ||
|
||||
(scheduleType === 'cron' &&
|
||||
schedule.cron_expression &&
|
||||
!validateCronExpression(schedule.cron_expression).valid)
|
||||
}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Timezone info - only show in simple mode */}
|
||||
{!advancedMode && schedule.enabled && schedule.time && (
|
||||
{scheduleType !== 'cron' && schedule.enabled && schedule.time && (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
System Timezone: {userTimezone} • Backup will run at{' '}
|
||||
{schedule.time} {userTimezone}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ import {
|
|||
Switch,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
|
||||
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
|
||||
import { SUBSCRIPTION_EVENTS } from '../../constants.js';
|
||||
|
||||
const PluginFieldList = ({ plugin, settings, updateField }) => {
|
||||
return plugin.fields.map((f) => (
|
||||
|
|
@ -44,6 +46,14 @@ const PluginActionList = ({
|
|||
{action.description}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" style={{ paddingTop: 10 }}>
|
||||
Event Triggers
|
||||
</Text>
|
||||
{action.events.map((event) => (
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
{SUBSCRIPTION_EVENTS[event] || event}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
loading={runningActionId === action.id}
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
background-color: transparent; /* Default background when not active */
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Active state styles */
|
||||
|
|
@ -19,9 +20,9 @@
|
|||
}
|
||||
|
||||
/* Hover effect */
|
||||
.navlink:hover {
|
||||
background-color: #2a2f34; /* Gray hover effect when not active */
|
||||
border: 1px solid #3d3d42;
|
||||
.navlink:hover, .navlink-parent-active {
|
||||
background-color: #2A2F34; /* Gray hover effect when not active */
|
||||
border: 1px solid #3D3D42;
|
||||
}
|
||||
|
||||
/* Hover effect for active state */
|
||||
|
|
@ -38,3 +39,20 @@
|
|||
.navlink:not(.navlink-collapsed) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Left indicator for open nav groups rendered outside buttons */
|
||||
.navgroup-open {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
.navgroup-open::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; /* avoid horizontal overflow; sits at container edge */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #3BA882;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -354,3 +354,21 @@ export const CONTAINER_EXTENSIONS = [
|
|||
'ts',
|
||||
'mpg',
|
||||
];
|
||||
|
||||
export const SUBSCRIPTION_EVENTS = {
|
||||
channel_start: 'Channel Started',
|
||||
channel_stop: 'Channel Stopped',
|
||||
channel_reconnect: 'Channel Reconnected',
|
||||
channel_error: 'Channel Error',
|
||||
channel_failover: 'Channel Failover',
|
||||
stream_switch: 'Stream Switch',
|
||||
recording_start: 'Recording Started',
|
||||
recording_end: 'Recording Ended',
|
||||
epg_refresh: 'EPG Refreshed',
|
||||
m3u_refresh: 'M3U Refreshed',
|
||||
client_connect: 'Client Connected',
|
||||
client_disconnect: 'Client Disconnected',
|
||||
login_failed: 'Login Failed',
|
||||
epg_blocked: 'EPG Blocked',
|
||||
m3u_blocked: 'M3U Blocked',
|
||||
};
|
||||
|
|
|
|||
203
frontend/src/pages/Connect.jsx
Normal file
203
frontend/src/pages/Connect.jsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Switch,
|
||||
Card,
|
||||
Flex,
|
||||
useMantineTheme,
|
||||
Text,
|
||||
Badge,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import API from '../api';
|
||||
import useConnectStore from '../store/connect';
|
||||
import { SquarePlus, Webhook, FileCode, Logs } from 'lucide-react';
|
||||
import ConnectionForm from '../components/forms/Connection';
|
||||
import { SUBSCRIPTION_EVENTS } from '../constants';
|
||||
|
||||
export default function ConnectPage() {
|
||||
const { integrations, isLoading, fetchIntegrations } = useConnectStore();
|
||||
const theme = useMantineTheme();
|
||||
const [connection, setConnection] = useState(null);
|
||||
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIntegrations();
|
||||
}, [fetchIntegrations]);
|
||||
|
||||
const newConnection = () => {
|
||||
setConnection(null);
|
||||
setIsConnectionModalOpen(true);
|
||||
};
|
||||
|
||||
const editConnection = (connection) => {
|
||||
setConnection(connection);
|
||||
setIsConnectionModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteConnection = async (id) => {
|
||||
console.log('Deleting connection', id);
|
||||
await API.deleteConnectIntegration(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => newConnection()}
|
||||
p={10}
|
||||
color={theme.tailwind.green[5]}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
New Connection
|
||||
</Button>
|
||||
{isLoading && <div>Loading...</div>}
|
||||
{!isLoading && (
|
||||
<Box
|
||||
style={{
|
||||
gap: '1rem',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))',
|
||||
alignContent: 'start',
|
||||
}}
|
||||
display="grid"
|
||||
py={10}
|
||||
>
|
||||
{integrations.map((i) => (
|
||||
<IntegrationRow
|
||||
key={i.id}
|
||||
integration={i}
|
||||
editConnection={editConnection}
|
||||
deleteConnection={deleteConnection}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ConnectionForm
|
||||
connection={connection}
|
||||
isOpen={isConnectionModalOpen}
|
||||
onClose={() => setIsConnectionModalOpen(false)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationRow({ integration, editConnection, deleteConnection }) {
|
||||
const type = integration.type || 'webhook';
|
||||
const [enabled, setEnabled] = useState(!!integration.enabled);
|
||||
const webhookUrl = integration?.config?.url || '';
|
||||
const scriptPath = integration?.config?.path || '';
|
||||
|
||||
const toggleIntegration = async () => {
|
||||
try {
|
||||
await API.updateConnectIntegration(integration.id, {
|
||||
...integration,
|
||||
enabled: !enabled,
|
||||
});
|
||||
setEnabled(!enabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to update integration', error);
|
||||
} finally {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={integration.id}
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
color="#fff"
|
||||
w={'100%'}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Group align="flex-start">
|
||||
{integration.type == 'webhook' ? <Webhook /> : <FileCode />}
|
||||
<Text fw={800}>{integration.name}</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
label="Enabled"
|
||||
checked={enabled}
|
||||
onChange={toggleIntegration}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{type === 'webhook' ? (
|
||||
<Group gap={5} align="center">
|
||||
<Text fw={500}>Target:</Text>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip label={webhookUrl} withArrow multiline>
|
||||
<Text
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{webhookUrl}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap={5} align="center">
|
||||
<Text fw={500}>Target:</Text>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip label={scriptPath} withArrow multiline>
|
||||
<Text
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{scriptPath}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Text>Triggers</Text>
|
||||
<Group>
|
||||
{integration.subscriptions.map(
|
||||
(sub) =>
|
||||
sub.enabled && (
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
{SUBSCRIPTION_EVENTS[sub.event] || sub.event}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button size="xs" onClick={() => editConnection(integration)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={() => deleteConnection(integration.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
299
frontend/src/pages/ConnectLogs.jsx
Normal file
299
frontend/src/pages/ConnectLogs.jsx
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Title,
|
||||
Badge,
|
||||
Group,
|
||||
Text,
|
||||
Paper,
|
||||
NativeSelect,
|
||||
Pagination,
|
||||
Select,
|
||||
LoadingOverlay,
|
||||
} from '@mantine/core';
|
||||
import API from '../api';
|
||||
import useConnectStore from '../store/connect';
|
||||
import { FileCode, Webhook } from 'lucide-react';
|
||||
import { SUBSCRIPTION_EVENTS } from '../constants';
|
||||
import { CustomTable, useTable } from '../components/tables/CustomTable';
|
||||
import { copyToClipboard } from '../utils';
|
||||
|
||||
export default function ConnectLogsPage() {
|
||||
const { integrations, fetchIntegrations } = useConnectStore();
|
||||
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [count, setCount] = useState(0);
|
||||
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 });
|
||||
const [filters, setFilters] = useState({ type: '', integration: '' });
|
||||
|
||||
const pageCount = useMemo(
|
||||
() => Math.max(1, Math.ceil(count / Math.max(1, pagination.pageSize))),
|
||||
[count, pagination.pageSize]
|
||||
);
|
||||
|
||||
const onPageSizeChange = useCallback((e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
setPagination((prev) => ({ ...prev, pageSize: value, pageIndex: 0 }));
|
||||
}, []);
|
||||
|
||||
const onPageIndexChange = useCallback((page) => {
|
||||
setPagination((prev) => ({ ...prev, pageIndex: page - 1 }));
|
||||
}, []);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
};
|
||||
if (filters.type) params.type = filters.type;
|
||||
if (filters.integration) params.integration = filters.integration;
|
||||
|
||||
const data = await API.getConnectLogs(params);
|
||||
const results = Array.isArray(data) ? data : data?.results || [];
|
||||
setLogs(results);
|
||||
setCount(data?.count || results.length || 0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [pagination.pageIndex, pagination.pageSize, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load integrations for filter options if not already available
|
||||
if (!integrations || integrations.length === 0) {
|
||||
fetchIntegrations?.();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Time',
|
||||
accessorKey: 'created_at',
|
||||
size: 180,
|
||||
cell: ({ getValue }) => (
|
||||
<Text size="sm">{new Date(getValue()).toLocaleString()}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Integration',
|
||||
accessorKey: 'subscription',
|
||||
size: 200,
|
||||
cell: ({ getValue }) => {
|
||||
const subscription = getValue();
|
||||
const integration = integrations.find(
|
||||
(i) => i.id === subscription?.integration
|
||||
);
|
||||
const isWebhook = integration?.type === 'webhook';
|
||||
return (
|
||||
<Group gap={6}>
|
||||
{isWebhook ? <Webhook size={16} /> : <FileCode size={16} />}
|
||||
<Text size="sm">{integration?.name || '-'}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Event',
|
||||
accessorKey: 'subscription',
|
||||
size: 160,
|
||||
cell: ({ getValue }) => (
|
||||
<Text size="sm">{SUBSCRIPTION_EVENTS[getValue()?.event] || '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Response',
|
||||
accessorKey: 'response_payload',
|
||||
grow: true,
|
||||
cell: ({ getValue }) => (
|
||||
<Text
|
||||
size="sm"
|
||||
truncate
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
copyToClipboard(getValue() ? JSON.stringify(getValue()) : '')
|
||||
}
|
||||
>
|
||||
{getValue() ? JSON.stringify(getValue()) : '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Error',
|
||||
accessorKey: 'error_message',
|
||||
size: 150,
|
||||
cell: ({ getValue }) => (
|
||||
<Text
|
||||
size="sm"
|
||||
truncate
|
||||
onClick={() => copyToClipboard(getValue() || '')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{getValue() || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<Badge
|
||||
color={getValue() === 'success' ? 'green' : 'red'}
|
||||
variant="light"
|
||||
>
|
||||
{getValue()}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
[integrations]
|
||||
);
|
||||
|
||||
const data = useMemo(() => logs, [logs]);
|
||||
const allRowIds = useMemo(() => logs.map((l) => l.id), [logs]);
|
||||
|
||||
const renderHeaderCell = (header) => (
|
||||
<Text size="sm" name={header.id}>
|
||||
{header.column.columnDef.header}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const table = useTable({
|
||||
columns,
|
||||
data,
|
||||
allRowIds,
|
||||
enablePagination: false,
|
||||
enableRowSelection: false,
|
||||
enableRowVirtualization: false,
|
||||
renderTopToolbar: false,
|
||||
manualSorting: false,
|
||||
manualFiltering: false,
|
||||
manualPagination: true,
|
||||
headerCellRenderFns: {
|
||||
created_at: renderHeaderCell,
|
||||
subscription: renderHeaderCell,
|
||||
response_payload: renderHeaderCell,
|
||||
error_message: renderHeaderCell,
|
||||
status: renderHeaderCell,
|
||||
},
|
||||
});
|
||||
|
||||
const startIdx = pagination.pageIndex * pagination.pageSize + 1;
|
||||
const endIdx = Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
count
|
||||
);
|
||||
const paginationString = `Showing ${startIdx}-${endIdx} of ${count}`;
|
||||
|
||||
const integrationOptions = useMemo(
|
||||
() => integrations.map((i) => ({ value: String(i.id), label: i.name })),
|
||||
[integrations]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Title order={3} fw={'bold'}>
|
||||
Connect Logs
|
||||
</Title>
|
||||
<Paper
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 65px)',
|
||||
backgroundColor: '#27272A',
|
||||
border: '1px solid #3f3f46',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
}}
|
||||
>
|
||||
<Group gap={12} p={12} style={{ borderBottom: '1px solid #3f3f46' }}>
|
||||
<Text size="sm">Type</Text>
|
||||
<Select
|
||||
size="xs"
|
||||
data={[
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'webhook', label: 'Webhooks' },
|
||||
{ value: 'script', label: 'Scripts' },
|
||||
]}
|
||||
value={filters.type}
|
||||
onChange={(value) =>
|
||||
setFilters((prev) => ({ ...prev, type: value }))
|
||||
}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Text size="sm">Integration</Text>
|
||||
<Select
|
||||
size="xs"
|
||||
searchable
|
||||
data={[{ value: '', label: 'All' }, ...integrationOptions]}
|
||||
value={filters.integration}
|
||||
onChange={(value) =>
|
||||
setFilters((prev) => ({ ...prev, integration: value }))
|
||||
}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 100px)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'auto',
|
||||
border: 'solid 1px rgb(68,68,68)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: '900px', position: 'relative' }}>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
<CustomTable table={table} />
|
||||
</div>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 3,
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap={5}
|
||||
justify="center"
|
||||
style={{ padding: 8, borderTop: '1px solid #666' }}
|
||||
>
|
||||
<Text size="xs">Page Size</Text>
|
||||
<NativeSelect
|
||||
size="xxs"
|
||||
value={pagination.pageSize}
|
||||
data={['25', '50', '100', '250']}
|
||||
onChange={onPageSizeChange}
|
||||
style={{ paddingRight: 20 }}
|
||||
/>
|
||||
<Pagination
|
||||
total={pageCount}
|
||||
value={pagination.pageIndex + 1}
|
||||
onChange={onPageIndexChange}
|
||||
size="xs"
|
||||
withEdges
|
||||
style={{ paddingRight: 20 }}
|
||||
/>
|
||||
<Text size="xs">{paginationString}</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
46
frontend/src/store/connect.jsx
Normal file
46
frontend/src/store/connect.jsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
|
||||
const useConnectStore = create((set, get) => ({
|
||||
integrations: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchIntegrations: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const list = await API.getConnectIntegrations();
|
||||
console.log(list);
|
||||
set({
|
||||
integrations: Array.isArray(list) ? list : list?.results || [],
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addIntegration: (integration) =>
|
||||
set((state) => ({ integrations: [...state.integrations, integration] })),
|
||||
|
||||
updateIntegration: (integration) =>
|
||||
set((state) => ({
|
||||
integrations: state.integrations.map((i) =>
|
||||
i.id === integration.id ? integration : i
|
||||
),
|
||||
})),
|
||||
|
||||
removeIntegration: (id) =>
|
||||
set((state) => ({
|
||||
integrations: state.integrations.filter((i) => i.id !== id),
|
||||
})),
|
||||
|
||||
updateIntegrationSubscriptions: (id, events) =>
|
||||
set((state) => ({
|
||||
integrations: state.integrations.map((i) =>
|
||||
i.id === id ? { ...i, subscriptions: events } : i
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useConnectStore;
|
||||
63
frontend/src/utils/cronUtils.js
Normal file
63
frontend/src/utils/cronUtils.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Cron expression validation utility.
|
||||
*
|
||||
* Shared across CronModal, BackupManager, and any other component
|
||||
* that needs to validate 5-part cron expressions.
|
||||
*/
|
||||
|
||||
export function validateCronExpression(expression) {
|
||||
if (!expression || expression.trim() === '') {
|
||||
return { valid: false, error: 'Cron expression is required' };
|
||||
}
|
||||
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'Cron expression must have exactly 5 parts: minute hour day month weekday',
|
||||
};
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
|
||||
const cronPartRegex =
|
||||
/^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/;
|
||||
|
||||
const fields = [
|
||||
{ value: minute, label: 'minute', min: 0, max: 59 },
|
||||
{ value: hour, label: 'hour', min: 0, max: 23 },
|
||||
{ value: dayOfMonth, label: 'day', min: 1, max: 31 },
|
||||
{ value: month, label: 'month', min: 1, max: 12 },
|
||||
{ value: dayOfWeek, label: 'weekday', min: 0, max: 6 },
|
||||
];
|
||||
|
||||
for (const { value, label, min, max } of fields) {
|
||||
if (!cronPartRegex.test(value)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid ${label} field (${min}-${max}, *, or cron syntax)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Extra numeric-range check for plain numbers
|
||||
if (
|
||||
!(
|
||||
value === '*' ||
|
||||
value.includes('/') ||
|
||||
value.includes('-') ||
|
||||
value.includes(',')
|
||||
)
|
||||
) {
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < min || num > max) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${label.charAt(0).toUpperCase() + label.slice(1)} must be between ${min} and ${max}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue