Merge remote-tracking branch 'origin/dev' into optimize-channels-store

This commit is contained in:
dekzter 2026-02-16 16:44:20 -05:00
commit cd6b3da8eb
47 changed files with 3417 additions and 649 deletions

View file

@ -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 />} />

View file

@ -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);
}
}
}

View file

@ -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

View file

@ -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}

View file

@ -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}

View 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;

View 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
&bull; <Code>0 9-17 * * 1-5</Code> hourly 9 AM-5 PM Mon-Fri
&bull; <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>
);
}

View file

@ -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>
</>
);
};

View file

@ -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

View file

@ -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

View 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>
);
}

View file

@ -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;
}

View file

@ -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',
};

View 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>
);
}

View 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>
);
}

View 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;

View 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 };
}