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