diff --git a/package-lock.json b/package-lock.json index fc91a54e..fc2f913e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react-router-dom": "7.6.3", "sonner": "2.0.6", "tailwind-merge": "3.3.1", + "uuid": "9.0.0", "zod": "3.25.74" }, "devDependencies": { @@ -9623,6 +9624,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.2.tgz", diff --git a/package.json b/package.json index 5b1b7d74..8f9ebfef 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "react-router-dom": "7.6.3", "sonner": "2.0.6", "tailwind-merge": "3.3.1", - "zod": "3.25.74" + "zod": "3.25.74", + "uuid": "9.0.0" }, "devDependencies": { "@commitlint/cli": "19.8.1", diff --git a/public/favicon.ico b/public/favicon.ico index 14593e76..1f6963f9 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/src/components/catalog/CatalogControlsStep.jsx b/src/components/catalog/CatalogControlsStep.jsx index bf21b94a..1aa958d4 100644 --- a/src/components/catalog/CatalogControlsStep.jsx +++ b/src/components/catalog/CatalogControlsStep.jsx @@ -1,26 +1,17 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Loader2, ArrowRight, Plus, Trash, Edit, AlertCircle } from 'lucide-react'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form'; +import { Loader2, ArrowRight, Plus, Trash, Edit, AlertCircle, ExternalLink } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { NewControlForm } from '@/forms/control/new/form'; +import { getControlsByCatalogId, deleteControl, createDraftControl } from '@/services/controls'; +import { createScopeSet } from '@/services/scopes'; +import { toast } from 'sonner'; import { Dialog, DialogContent, @@ -28,60 +19,45 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog'; -import { cn } from '@/lib/utils'; +import { getScopeSetsByControlId } from '@/services/scopes'; import { Badge } from '@/components/ui/badge'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; - -// Schema for control validation -const controlSchema = z.object({ - name: z.string().min(1, { message: 'Control name is required' }), - description: z.string().min(1, { message: 'Description is required' }), - type: z.string().min(1, { message: 'Control type is required' }), - severity: z.string().min(1, { message: 'Severity is required' }), - implementation: z.string().optional(), -}); - -// Control types and severity options -const controlTypes = [ - { value: 'preventive', label: 'Preventive' }, - { value: 'detective', label: 'Detective' }, - { value: 'corrective', label: 'Corrective' }, - { value: 'deterrent', label: 'Deterrent' }, - { value: 'recovery', label: 'Recovery' }, -]; - -const severityLevels = [ - { value: 'critical', label: 'Critical', color: 'bg-red-500' }, - { value: 'high', label: 'High', color: 'bg-orange-500' }, - { value: 'medium', label: 'Medium', color: 'bg-yellow-500' }, - { value: 'low', label: 'Low', color: 'bg-green-500' }, - { value: 'informational', label: 'Informational', color: 'bg-blue-500' }, -]; +import { saveDraftControlId, saveDraftControlIds, saveDraftCatalogId, initializeControlIdsStorage } from '@/utils/draftStorage'; +import { dashboardsService } from '@/services/grafana/dashboards'; +import { saveDraftDashboardUid } from '@/utils/draftStorage'; export function CatalogControlsStep({ initialControls = [], catalogId, onSubmit, isSubmitting, apiError = null }) { const [controls, setControls] = useState([]); - const [isEditing, setIsEditing] = useState(false); - const [currentControl, setCurrentControl] = useState(null); + const [isLoading, setIsLoading] = useState(false); const [openDialog, setOpenDialog] = useState(false); + const [openNewControlForm, setOpenNewControlForm] = useState(false); const [submitError, setSubmitError] = useState(null); + const [currentControl, setCurrentControl] = useState(null); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const initialFetchCompleted = useRef(false); - // Setup form with zod resolver - const form = useForm({ - resolver: zodResolver(controlSchema), - defaultValues: { - name: '', - description: '', - type: '', - severity: '', - implementation: '', - }, - }); + const nodeRedUrl = import.meta.env.VITE_NODE_RED_URL || 'http://localhost:1880'; + // Save catalogId to localStorage and initialize controls storage useEffect(() => { + // Ensure the control_draft_ids key exists in localStorage + initializeControlIdsStorage(); + if (initialControls && initialControls.length > 0) { - setControls([...initialControls]); + setControls(initialControls); + + // Save control IDs to localStorage + const controlIds = initialControls.map(control => control.id).filter(Boolean); + if (controlIds.length > 0) { + saveDraftControlIds(controlIds); + } + } + else if (catalogId && !initialFetchCompleted.current) { + saveDraftCatalogId(catalogId); + fetchDraftControls(); + initialFetchCompleted.current = true; } - }, [initialControls]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [catalogId]); // Update error message when API error changes useEffect(() => { @@ -90,65 +66,180 @@ export function CatalogControlsStep({ initialControls = [], catalogId, onSubmit, } }, [apiError]); + const fetchDraftControls = async () => { + if (!catalogId) return; + + setIsLoading(true); + try { + const response = await getControlsByCatalogId(catalogId, 'draft'); + + // Handle case where the response is empty or undefined + if (!response || response.length === 0) { + setControls([]); + // Save an empty array to localStorage to indicate we've fetched controls (even if empty) + saveDraftControlIds([]); + setIsLoading(false); + return; + } + + const controlsWithScopesData = await Promise.all( + response.map(async (control) => { + try { + const scopeResponse = await getScopeSetsByControlId(control.id); + return { + ...control, + scopes: scopeResponse && scopeResponse.length > 0 + ? scopeResponse.reduce((acc, scopeSet) => { + return { ...acc, ...scopeSet.scopes }; + }, {}) + : {} + }; + } catch (error) { + console.error(`Error fetching scopes for control ${control.id}:`, error); + return { ...control, scopes: {} }; + } + }) + ); + + setControls(controlsWithScopesData); + + // Save control IDs to localStorage + const controlIds = controlsWithScopesData.map(control => control.id).filter(Boolean); + if (controlIds.length > 0) { + saveDraftControlIds(controlIds); + } else { + // Initialize with empty array if no controls + saveDraftControlIds([]); + } + } catch (error) { + console.error('Error fetching draft controls:', error); + toast.error('Failed to load draft controls'); + setControls([]); + } finally { + setIsLoading(false); + } + }; + const handleAddControl = () => { - setIsEditing(false); - setCurrentControl(null); - form.reset({ - name: '', - description: '', - type: '', - severity: '', - implementation: '', - }); - setOpenDialog(true); + setOpenNewControlForm(true); }; - const handleEditControl = (control, index) => { - setIsEditing(true); - setCurrentControl({ ...control, index }); - form.reset({ - name: control.name, - description: control.description, - type: control.type, - severity: control.severity, - implementation: control.implementation || '', - }); - setOpenDialog(true); + const handleCloseNewControlForm = () => { + setOpenNewControlForm(false); }; - const handleDeleteControl = (index) => { - const updatedControls = [...controls]; - updatedControls.splice(index, 1); - setControls(updatedControls); + const handleCustomSubmit = async (data) => { + try { + // Make sure the catalog ID is included in the control data + if (catalogId) { + data.catalogId = catalogId; + // Save catalog ID to localStorage again for safety + saveDraftCatalogId(catalogId); + // Ensure control_draft_ids is initialized + initializeControlIdsStorage(); + } + + let scopeSet = { scopes: {} }; + // Use createDraftControl specifically in the catalog context + const createdControl = await createDraftControl(data); + + // Save the new control ID to localStorage + if (createdControl && createdControl.id) { + saveDraftControlId(createdControl.id); + } + + // Create the scope set if needed + if (Object.keys(data.scopes).length > 0) { + const scopeSetData = { + controlId: createdControl.id, + scopes: data.scopes + }; + + scopeSet = await createScopeSet(scopeSetData); + } + + return { ...createdControl, scopes: scopeSet.scopes }; + } catch (error) { + console.error('Error creating draft control:', error); + throw error; + } }; - const handleControlSubmit = (data) => { - if (isEditing && currentControl) { - // Update existing control - const updatedControls = [...controls]; - updatedControls[currentControl.index] = { - id: currentControl.id || `temp-${Date.now()}`, - ...data - }; - setControls(updatedControls); - } else { - // Add new control - setControls([ - ...controls, - { - id: `temp-${Date.now()}`, - ...data + const handleControlSuccess = async (newControl) => { + setOpenNewControlForm(false); + + if (newControl && newControl.id) { + try { + // Fetch the updated list of controls + if (catalogId) { + const response = await getControlsByCatalogId(catalogId, 'draft'); + const controlsWithScopesData = await Promise.all( + response.map(async (control) => { + try { + const scopeResponse = await getScopeSetsByControlId(control.id); + return { + ...control, + scopes: scopeResponse.reduce((acc, scopeSet) => { + return { ...acc, ...scopeSet.scopes }; + }, {}) + }; + } catch (error) { + console.error(`Error fetching scopes for control ${control.id}:`, error); + return { ...control, scopes: {} }; + } + }) + ); + setControls(controlsWithScopesData || []); + } else { + // If no catalogId, manually add the new control to the current list + setControls(prevControls => [...prevControls, newControl]); } - ]); + } catch (error) { + console.error('Error updating controls list:', error); + toast.error('Control was created but the list could not be updated'); + + // As a fallback, manually add the control to the list + setControls(prevControls => [...prevControls, newControl]); + } } - setOpenDialog(false); }; - const closeDialog = () => { + const handleEditControl = (control) => { + // Placeholder for edit functionality + toast.info('Edit functionality will be available in a future update'); + }; + + const handleDeleteConfirm = (control) => { + setCurrentControl(control); + setConfirmingDelete(true); + setOpenDialog(true); + }; + + const handleDeleteCancel = () => { setOpenDialog(false); + setConfirmingDelete(false); + setCurrentControl(null); + }; + + const handleDeleteControl = async () => { + if (!currentControl || !currentControl.id) return; + + try { + await deleteControl(currentControl.id); + // After deletion, refresh the draft controls list + await fetchDraftControls(); + toast.success('Control deleted successfully'); + } catch (error) { + console.error('Error deleting draft control:', error); + toast.error('Failed to delete draft control'); + } finally { + setOpenDialog(false); + setConfirmingDelete(false); + setCurrentControl(null); + } }; - const handleSubmit = () => { + const handleSubmit = async () => { setSubmitError(null); if (controls.length === 0) { @@ -156,27 +247,58 @@ export function CatalogControlsStep({ initialControls = [], catalogId, onSubmit, return; } - onSubmit(controls); - }; + // Save control IDs and catalog ID to localStorage before submitting + const controlIds = controls.map(control => control.id).filter(Boolean); + if (controlIds.length > 0) { + saveDraftControlIds(controlIds); + } + + if (catalogId) { + saveDraftCatalogId(catalogId); + } + + try { + const dashboardName = `tmp-${Date.now()}-${catalogId}`; + + // Use the start date of the first control or the current date + let startDate = 'now-24h'; + if (controls.length > 0 && controls[0].startDate) { + startDate = new Date(controls[0].startDate).toISOString(); + } + + // Create the template dashboard + const dashboardResponse = await dashboardsService.createTemplate({ + name: dashboardName, + timeRange: { + from: startDate, + to: 'now' + } + }); - const getSeverityBadge = (severity) => { - const severityInfo = severityLevels.find(level => level.value === severity); - return ( - - {severityInfo ? severityInfo.label : severity} - - ); + // Store the dashboard UID in localStorage + if (dashboardResponse?.dashboard && dashboardResponse.dashboard?.uid) { + const dashboardUid = dashboardResponse?.dashboard?.uid; + saveDraftDashboardUid(dashboardUid); + } + } catch (error) { + console.error('Error creating template dashboard:', error); + toast.warning('Could not create template dashboard, panels may not display correctly'); + } + + onSubmit(controls); }; return (
-

Security Controls

+

Draft Controls

@@ -192,32 +314,47 @@ export function CatalogControlsStep({ initialControls = [], catalogId, onSubmit, )} - {/* Controls list */} + {/* Empty state with helpful message */} + {!isLoading && controls.length === 0 && ( +
+

No draft controls yet

+

Add draft controls to define what to monitor in your catalog

+
+ )} + + {/* Controls list with shimmer effect when loading */}
- {controls.length === 0 ? ( -
-

No controls added yet. Click "Add Control" to start.

-
- ) : ( - controls.map((control, index) => ( - + {isLoading ? ( + // Shimmer loading effect for controls + Array(3).fill(0).map((_, index) => ( +
+
+
+ )) + ) : controls.length > 0 ? ( + controls.map((control) => ( + -
+
{control.name} - - {getSeverityBadge(control.severity)} -

- Type: {controlTypes.find(type => type.value === control.type)?.label || control.type} + Period: {control.period}

+ {control.mashupId && ( +
+ +
+ )} @@ -225,168 +362,100 @@ export function CatalogControlsStep({ initialControls = [], catalogId, onSubmit, size="sm" variant="ghost" className="text-red-500 hover:bg-red-50 hover:text-red-700" - onClick={() => handleDeleteControl(index)} + onClick={() => handleDeleteConfirm(control)} >
- -

{control.description}

- {control.implementation && ( -
- Implementation: -

{control.implementation}

-
- )} + + + +
+

Description: + {control.description}

+ +
Start Date: {new Date(control.startDate).toLocaleDateString()}
+ {control.endDate &&
End Date: {new Date(control.endDate).toLocaleDateString()}
} + {control.scopes && Object.keys(control.scopes).length > 0 && ( +
+ Scopes: + {Object.entries(control.scopes).map(([key, value]) => ( + + {key}: {value} + + ))} +
+ )} + {control.params && Object.keys(control.params).length > 0 && ( +
+ Parameters: + {Object.entries(control.params).map(([key, value]) => ( + + {key}: {typeof value === 'object' ? JSON.stringify(value) : value} + + ))} +
+ )} +
)) - )} + ) : null}
- {/* Control form dialog */} - - + {/* Draft Control Form - Pass customSubmit for draft control creation */} + {openNewControlForm && ( + + )} + + {/* Delete Confirmation Dialog */} + + - {isEditing ? 'Edit Control' : 'Add New Control'} + Delete Draft Control - -
- - ( - - Control Name * - - - - - - )} - /> - -
- ( - - Type * - - - - )} - /> - - ( - - Severity * - - - - )} - /> -
- - ( - - Description * - -