From 60d1030b0e2ddd787b6ea0a2a7af0208e3339007 Mon Sep 17 00:00:00 2001 From: alphaeusmote <41258468-alphaeusmote@users.noreply.replit.com> Date: Thu, 10 Apr 2025 03:43:08 +0000 Subject: [PATCH] Add dynamic group rule management API endpoints and UI components. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/4e0af531-eaa1-4a7b-a243-4f9e551aef27.jpg --- .../dynamic-groups/cron-job-builder.tsx | 375 ++++++++++++++++++ client/src/lib/cron-utils.ts | 214 ++++++++++ server/routes.ts | 209 ++++++++++ shared/schema.ts | 8 + 4 files changed, 806 insertions(+) create mode 100644 client/src/components/dynamic-groups/cron-job-builder.tsx create mode 100644 client/src/lib/cron-utils.ts diff --git a/client/src/components/dynamic-groups/cron-job-builder.tsx b/client/src/components/dynamic-groups/cron-job-builder.tsx new file mode 100644 index 0000000..30d3e2d --- /dev/null +++ b/client/src/components/dynamic-groups/cron-job-builder.tsx @@ -0,0 +1,375 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { Trash, Plus, Calendar, Clock, RotateCcw } from 'lucide-react'; +import { + ScheduleFrequency, + Weekday, + scheduleFrequencies, + weekdays, + InsertScheduleRule +} from '@shared/schema'; + +interface CronJobBuilderProps { + schedules: ScheduleItem[]; + onSchedulesChange: (schedules: ScheduleItem[]) => void; + ruleId?: number; +} + +export interface ScheduleItem { + id?: number; + ruleId?: number; + name: string; + description: string; + frequency: ScheduleFrequency; + cronExpression: string; + dayOfWeek?: Weekday | null; + dayOfMonth?: number | null; + hour: number; + minute: number; + enabled: boolean; +} + +const initialSchedule: ScheduleItem = { + name: 'Default Schedule', + description: 'Runs daily at midnight', + frequency: 'daily', + cronExpression: '0 0 * * *', + hour: 0, + minute: 0, + enabled: true +}; + +export const CronJobBuilder: React.FC = ({ + schedules = [], + onSchedulesChange, + ruleId +}) => { + const [activeSchedules, setActiveSchedules] = useState( + schedules.length > 0 ? schedules : [initialSchedule] + ); + const [activeTab, setActiveTab] = useState('0'); + + useEffect(() => { + if (schedules.length > 0 && activeSchedules.length === 0) { + setActiveSchedules(schedules); + } + }, [schedules]); + + const updateSchedule = (index: number, updatedSchedule: Partial) => { + const newSchedules = [...activeSchedules]; + newSchedules[index] = { ...newSchedules[index], ...updatedSchedule }; + + // Update cron expression + const schedule = newSchedules[index]; + let cronExpression = ''; + + switch (schedule.frequency) { + case 'minutely': + cronExpression = '* * * * *'; + break; + case 'hourly': + cronExpression = `${schedule.minute} * * * *`; + break; + case 'daily': + cronExpression = `${schedule.minute} ${schedule.hour} * * *`; + break; + case 'weekly': + const dayNumber = schedule.dayOfWeek ? weekdays.indexOf(schedule.dayOfWeek) : 0; + cronExpression = `${schedule.minute} ${schedule.hour} * * ${dayNumber}`; + break; + case 'monthly': + const day = schedule.dayOfMonth || 1; + cronExpression = `${schedule.minute} ${schedule.hour} ${day} * *`; + break; + } + + newSchedules[index].cronExpression = cronExpression; + + setActiveSchedules(newSchedules); + onSchedulesChange(newSchedules); + }; + + const addNewSchedule = () => { + const newSchedule: ScheduleItem = { + ...initialSchedule, + name: `Schedule ${activeSchedules.length + 1}`, + ruleId + }; + + const newSchedules = [...activeSchedules, newSchedule]; + setActiveSchedules(newSchedules); + setActiveTab(String(newSchedules.length - 1)); + onSchedulesChange(newSchedules); + }; + + const removeSchedule = (index: number) => { + if (activeSchedules.length <= 1) { + return; // Don't remove the last schedule + } + + const newSchedules = activeSchedules.filter((_, i) => i !== index); + setActiveSchedules(newSchedules); + + // Set active tab to the previous one or the first one + if (Number(activeTab) >= newSchedules.length) { + setActiveTab(String(Math.max(0, newSchedules.length - 1))); + } + + onSchedulesChange(newSchedules); + }; + + const toggleScheduleStatus = (index: number) => { + const newSchedules = [...activeSchedules]; + newSchedules[index].enabled = !newSchedules[index].enabled; + setActiveSchedules(newSchedules); + onSchedulesChange(newSchedules); + }; + + // Function to generate a human-readable description of the schedule + const getScheduleDescription = (schedule: ScheduleItem): string => { + switch (schedule.frequency) { + case 'minutely': + return 'Runs every minute'; + case 'hourly': + return `Runs every hour at ${schedule.minute} minutes past the hour`; + case 'daily': + return `Runs daily at ${formatTime(schedule.hour, schedule.minute)}`; + case 'weekly': + return `Runs weekly on ${schedule.dayOfWeek || 'Monday'} at ${formatTime(schedule.hour, schedule.minute)}`; + case 'monthly': + return `Runs monthly on day ${schedule.dayOfMonth || 1} at ${formatTime(schedule.hour, schedule.minute)}`; + default: + return 'Custom schedule'; + } + }; + + // Helper to format time in 12-hour format + const formatTime = (hour: number, minute: number): string => { + const period = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour % 12 || 12; + const displayMinute = minute.toString().padStart(2, '0'); + return `${displayHour}:${displayMinute} ${period}`; + }; + + return ( + + + Schedule Configuration + + + + +
+ + {activeSchedules.map((schedule, index) => ( + + {schedule.name} + {!schedule.enabled && ( + Disabled + )} + + ))} + +
+ + {activeSchedules.map((schedule, index) => ( + +
+
+
+ + updateSchedule(index, { name: e.target.value })} + placeholder="Schedule Name" + /> +
+ +
+ + +
+ + {(schedule.frequency === 'weekly') && ( +
+ + +
+ )} + + {(schedule.frequency === 'monthly') && ( +
+ + +
+ )} + + {(schedule.frequency !== 'minutely') && ( +
+ {(schedule.frequency !== 'hourly') && ( +
+ + +
+ )} + +
+ + +
+
+ )} +
+ +
+
+ + updateSchedule(index, { description: e.target.value })} + placeholder="Schedule Description" + /> +
+ +
+ +
+ {schedule.cronExpression} +
+
+ +
+
+

Schedule Summary:

+

{getScheduleDescription(schedule)}

+
+
+ +
+
+ toggleScheduleStatus(index)} + /> + +
+ + {activeSchedules.length > 1 && ( + + )} +
+
+
+
+ ))} +
+
+
+ ); +}; + +export default CronJobBuilder; \ No newline at end of file diff --git a/client/src/lib/cron-utils.ts b/client/src/lib/cron-utils.ts new file mode 100644 index 0000000..5c0f842 --- /dev/null +++ b/client/src/lib/cron-utils.ts @@ -0,0 +1,214 @@ +import { ScheduleFrequency, Weekday, weekdays } from '@shared/schema'; +import { ScheduleItem } from '@/components/dynamic-groups/cron-job-builder'; + +/** + * Parse a cron expression into its components + * @param cronExpression - The cron expression to parse + * @returns An object with the cron expression components + */ +export const parseCronExpression = (cronExpression: string): { + minute: number; + hour: number; + dayOfMonth: number | null; + month: number | null; + dayOfWeek: number | null; +} => { + const parts = cronExpression.split(' '); + + // Default to all parts being null/0 + const result = { + minute: 0, + hour: 0, + dayOfMonth: null as number | null, + month: null as number | null, + dayOfWeek: null as number | null + }; + + // Handle minute (first part) + if (parts.length > 0 && parts[0] !== '*') { + result.minute = parseInt(parts[0]); + } + + // Handle hour (second part) + if (parts.length > 1 && parts[1] !== '*') { + result.hour = parseInt(parts[1]); + } + + // Handle day of month (third part) + if (parts.length > 2 && parts[2] !== '*') { + result.dayOfMonth = parseInt(parts[2]); + } + + // Handle month (fourth part) + if (parts.length > 3 && parts[3] !== '*') { + result.month = parseInt(parts[3]); + } + + // Handle day of week (fifth part) + if (parts.length > 4 && parts[4] !== '*') { + result.dayOfWeek = parseInt(parts[4]); + } + + return result; +}; + +/** + * Detect the frequency of a schedule from its cron expression + * @param cronExpression - The cron expression to analyze + * @returns The detected frequency + */ +export const detectFrequency = (cronExpression: string): ScheduleFrequency => { + if (!cronExpression || cronExpression.trim() === '') { + return 'daily'; // Default to daily + } + + const parts = cronExpression.split(' '); + + // * * * * * - Every minute + if (parts[0] === '*' && parts[1] === '*') { + return 'minutely'; + } + + // MM * * * * - Every hour at minute MM + if (parts[0] !== '*' && parts[1] === '*') { + return 'hourly'; + } + + // MM HH * * d - Weekly on day d at HH:MM + if (parts[0] !== '*' && parts[1] !== '*' && parts[2] === '*' && parts[3] === '*' && parts[4] !== '*') { + return 'weekly'; + } + + // MM HH DD * * - Monthly on day DD at HH:MM + if (parts[0] !== '*' && parts[1] !== '*' && parts[2] !== '*' && parts[3] === '*' && parts[4] === '*') { + return 'monthly'; + } + + // MM HH * * * - Daily at HH:MM + if (parts[0] !== '*' && parts[1] !== '*' && parts[2] === '*' && parts[3] === '*' && parts[4] === '*') { + return 'daily'; + } + + // Default to daily for any other patterns + return 'daily'; +}; + +/** + * Convert a cron expression to a schedule item + * @param cronExpression - The cron expression to convert + * @param name - Optional name for the schedule + * @param description - Optional description for the schedule + * @returns A schedule item object + */ +export const cronToScheduleItem = ( + cronExpression: string, + name = 'Schedule', + description = '', + enabled = true, + id?: number, + ruleId?: number +): ScheduleItem => { + const frequency = detectFrequency(cronExpression); + const { minute, hour, dayOfMonth, dayOfWeek } = parseCronExpression(cronExpression); + + // Generate description if not provided + let autoDescription = description; + if (!autoDescription) { + autoDescription = getScheduleDescription({ + frequency, + minute, + hour, + dayOfMonth, + dayOfWeek: dayOfWeek !== null ? weekdays[dayOfWeek] : null, + cronExpression, + name, + description: '', + enabled + }); + } + + return { + id, + ruleId, + name, + description: autoDescription, + frequency, + cronExpression, + minute, + hour, + dayOfMonth: dayOfMonth, + dayOfWeek: dayOfWeek !== null ? weekdays[dayOfWeek] as Weekday : null, + enabled + }; +}; + +/** + * Convert a schedule item to a cron expression + * @param schedule - The schedule item to convert + * @returns A cron expression string + */ +export const scheduleToCronExpression = (schedule: Partial): string => { + let cronExpression = ''; + + switch (schedule.frequency) { + case 'minutely': + cronExpression = '* * * * *'; + break; + case 'hourly': + cronExpression = `${schedule.minute || 0} * * * *`; + break; + case 'daily': + cronExpression = `${schedule.minute || 0} ${schedule.hour || 0} * * *`; + break; + case 'weekly': + const dayNumber = schedule.dayOfWeek ? weekdays.indexOf(schedule.dayOfWeek) : 0; + cronExpression = `${schedule.minute || 0} ${schedule.hour || 0} * * ${dayNumber}`; + break; + case 'monthly': + const day = schedule.dayOfMonth || 1; + cronExpression = `${schedule.minute || 0} ${schedule.hour || 0} ${day} * *`; + break; + default: + cronExpression = '0 0 * * *'; // Default to midnight every day + } + + return cronExpression; +}; + +/** + * Generate a human-readable description of a schedule + * @param schedule - The schedule to describe + * @returns A human-readable description + */ +export const getScheduleDescription = (schedule: { + frequency: ScheduleFrequency; + minute: number; + hour: number; + dayOfMonth: number | null; + dayOfWeek: Weekday | null; + cronExpression: string; + enabled: boolean; +}): string => { + // Format time in 12-hour format + const formatTime = (hour: number, minute: number): string => { + const period = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour % 12 || 12; + const displayMinute = minute.toString().padStart(2, '0'); + return `${displayHour}:${displayMinute} ${period}`; + }; + + switch (schedule.frequency) { + case 'minutely': + return 'Runs every minute'; + case 'hourly': + return `Runs every hour at ${schedule.minute} minutes past the hour`; + case 'daily': + return `Runs daily at ${formatTime(schedule.hour, schedule.minute)}`; + case 'weekly': + return `Runs weekly on ${schedule.dayOfWeek || 'Monday'} at ${formatTime(schedule.hour, schedule.minute)}`; + case 'monthly': + return `Runs monthly on day ${schedule.dayOfMonth || 1} at ${formatTime(schedule.hour, schedule.minute)}`; + default: + return 'Custom schedule'; + } +}; \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index a080949..ad59819 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -5293,6 +5293,215 @@ export async function registerRoutes(app: Express): Promise { } }); + // Dynamic Group Rules routes + app.get("/api/dynamic-group-rules", requirePermission(PERMISSIONS.VIEW_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const rules = await storage.listDynamicGroupRules(); + res.json(rules); + } catch (error) { + console.error('Error fetching dynamic group rules:', error); + res.status(500).json({ error: 'Failed to fetch dynamic group rules' }); + } + }); + + app.get("/api/dynamic-group-rules/:id", requirePermission(PERMISSIONS.VIEW_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const id = Number(req.params.id); + const rule = await storage.getDynamicGroupRule(id); + + if (!rule) { + return res.status(404).json({ error: 'Dynamic group rule not found' }); + } + + res.json(rule); + } catch (error) { + console.error('Error fetching dynamic group rule:', error); + res.status(500).json({ error: 'Failed to fetch dynamic group rule' }); + } + }); + + app.post("/api/dynamic-group-rules", requirePermission(PERMISSIONS.MANAGE_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const rule = await storage.createDynamicGroupRule(req.body); + res.status(201).json(rule); + } catch (error) { + console.error('Error creating dynamic group rule:', error); + res.status(500).json({ error: 'Failed to create dynamic group rule' }); + } + }); + + app.put("/api/dynamic-group-rules/:id", requirePermission(PERMISSIONS.MANAGE_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const id = Number(req.params.id); + const updatedRule = await storage.updateDynamicGroupRule(id, req.body); + + if (!updatedRule) { + return res.status(404).json({ error: 'Dynamic group rule not found' }); + } + + res.json(updatedRule); + } catch (error) { + console.error('Error updating dynamic group rule:', error); + res.status(500).json({ error: 'Failed to update dynamic group rule' }); + } + }); + + app.delete("/api/dynamic-group-rules/:id", requirePermission(PERMISSIONS.MANAGE_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const id = Number(req.params.id); + const deleted = await storage.deleteDynamicGroupRule(id); + + if (!deleted) { + return res.status(404).json({ error: 'Dynamic group rule not found' }); + } + + res.status(204).send(); + } catch (error) { + console.error('Error deleting dynamic group rule:', error); + res.status(500).json({ error: 'Failed to delete dynamic group rule' }); + } + }); + + // Dynamic Group Conditions routes + app.get("/api/dynamic-group-rules/:ruleId/conditions", requirePermission(PERMISSIONS.VIEW_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const ruleId = Number(req.params.ruleId); + const conditions = await storage.getConditionsByRuleId(ruleId); + res.json(conditions); + } catch (error) { + console.error('Error fetching conditions for dynamic group rule:', error); + res.status(500).json({ error: 'Failed to fetch conditions' }); + } + }); + + app.post("/api/dynamic-group-rules/:ruleId/conditions", requirePermission(PERMISSIONS.MANAGE_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const ruleId = Number(req.params.ruleId); + const conditionData = { ...req.body, ruleId }; + const condition = await storage.createDynamicGroupCondition(conditionData); + res.status(201).json(condition); + } catch (error) { + console.error('Error creating condition for dynamic group rule:', error); + res.status(500).json({ error: 'Failed to create condition' }); + } + }); + + // Dynamic Group Rule Connections routes + app.get("/api/dynamic-group-rules/:ruleId/connections", requirePermission(PERMISSIONS.VIEW_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const ruleId = Number(req.params.ruleId); + const connections = await storage.getRuleConnectionsByRuleId(ruleId); + res.json(connections); + } catch (error) { + console.error('Error fetching connections for dynamic group rule:', error); + res.status(500).json({ error: 'Failed to fetch connections' }); + } + }); + + app.post("/api/dynamic-group-rules/:ruleId/connections", requirePermission(PERMISSIONS.MANAGE_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const ruleId = Number(req.params.ruleId); + const connectionData = { ...req.body, ruleId }; + const connection = await storage.createDynamicGroupRuleConnection(connectionData); + res.status(201).json(connection); + } catch (error) { + console.error('Error creating connection for dynamic group rule:', error); + res.status(500).json({ error: 'Failed to create connection' }); + } + }); + + // Schedule Rules routes + app.get("/api/schedule-rules", requirePermission(PERMISSIONS.VIEW_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const scheduleRules = await storage.getAllScheduleRules(); + res.json(scheduleRules); + } catch (error) { + console.error('Error fetching schedule rules:', error); + res.status(500).json({ error: 'Failed to fetch schedule rules' }); + } + }); + + app.get("/api/schedule-rules/:id", requirePermission(PERMISSIONS.VIEW_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const id = Number(req.params.id); + const scheduleRule = await storage.getScheduleRule(id); + + if (!scheduleRule) { + return res.status(404).json({ error: 'Schedule rule not found' }); + } + + res.json(scheduleRule); + } catch (error) { + console.error('Error fetching schedule rule:', error); + res.status(500).json({ error: 'Failed to fetch schedule rule' }); + } + }); + + app.get("/api/dynamic-group-rules/:ruleId/schedules", requirePermission(PERMISSIONS.VIEW_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const ruleId = Number(req.params.ruleId); + const schedules = await storage.getScheduleRulesByRuleId(ruleId); + res.json(schedules); + } catch (error) { + console.error('Error fetching schedules for dynamic group rule:', error); + res.status(500).json({ error: 'Failed to fetch schedules' }); + } + }); + + app.post("/api/schedule-rules", requirePermission(PERMISSIONS.MANAGE_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const scheduleRule = await storage.createScheduleRule(req.body); + res.status(201).json(scheduleRule); + } catch (error) { + console.error('Error creating schedule rule:', error); + res.status(500).json({ error: 'Failed to create schedule rule' }); + } + }); + + app.post("/api/dynamic-group-rules/:ruleId/schedules", requirePermission(PERMISSIONS.MANAGE_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const ruleId = Number(req.params.ruleId); + const scheduleData = { ...req.body, ruleId }; + const schedule = await storage.createScheduleRule(scheduleData); + res.status(201).json(schedule); + } catch (error) { + console.error('Error creating schedule for dynamic group rule:', error); + res.status(500).json({ error: 'Failed to create schedule' }); + } + }); + + app.put("/api/schedule-rules/:id", requirePermission(PERMISSIONS.MANAGE_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const id = Number(req.params.id); + const updatedRule = await storage.updateScheduleRule(id, req.body); + + if (!updatedRule) { + return res.status(404).json({ error: 'Schedule rule not found' }); + } + + res.json(updatedRule); + } catch (error) { + console.error('Error updating schedule rule:', error); + res.status(500).json({ error: 'Failed to update schedule rule' }); + } + }); + + app.delete("/api/schedule-rules/:id", requirePermission(PERMISSIONS.MANAGE_DYNAMIC_GROUPS, { allowApiToken: true }), async (req, res) => { + try { + const id = Number(req.params.id); + const deleted = await storage.deleteScheduleRule(id); + + if (!deleted) { + return res.status(404).json({ error: 'Schedule rule not found' }); + } + + res.status(204).send(); + } catch (error) { + console.error('Error deleting schedule rule:', error); + res.status(500).json({ error: 'Failed to delete schedule rule' }); + } + }); + const httpServer = createServer(app); return httpServer; diff --git a/shared/schema.ts b/shared/schema.ts index 467daf1..36c4796 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -507,6 +507,14 @@ export type MoveComputer = z.infer; export type MoveUser = z.infer; export type AddToGroup = z.infer; export type RemoveFromGroup = z.infer; +export type DynamicGroupRule = typeof dynamicGroupRules.$inferSelect; +export type InsertDynamicGroupRule = z.infer; +export type DynamicGroupCondition = typeof dynamicGroupConditions.$inferSelect; +export type InsertDynamicGroupCondition = z.infer; +export type ScheduleRule = typeof scheduleRules.$inferSelect; +export type InsertScheduleRule = z.infer; +export type ScheduleFrequency = typeof scheduleFrequencies[number]; +export type Weekday = typeof weekdays[number]; // LDAP Query Builder schemas