Unverified Commit 0809943a authored by Jorge Moratinos's avatar Jorge Moratinos Committed by GitHub
Browse files

Merge pull request #10 from Telefonica/config-page

Config page
parents be0ff3c7 405e7079
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -40,6 +40,7 @@
    "@reduxjs/toolkit": "1.9.5",
    "@tanstack/react-query": "^5.8.9",
    "aes-js": "^3.1.2",
    "ajv": "^8.17.1",
    "apexcharts-clevision": "3.28.5",
    "axios": "1.4.0",
    "axios-mock-adapter": "1.21.4",
+572 −0
Original line number Diff line number Diff line
import React, { useEffect, useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { getConfiguration, updateConfigParam, replaceConfiguration, addCategoryConfiguration, addParamConfiguration, removeConfigParam, removeConfigCategory } from 'src/configs/apiService';
import {
  Grid,
  Card,
  Typography,
  CardHeader,
  CardContent,
  TextField,
  Button,
  IconButton,
  Accordion,
  AccordionSummary,
  AccordionDetails,
  Box,
  Dialog,
  DialogTitle,
  DialogContent,
  DialogActions
} from '@mui/material';
import DownloadIcon from '@mui/icons-material/Download';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import DeleteIcon from '@mui/icons-material/Delete';
import Ajv from 'ajv';

const ajv = new Ajv();

const ConfigCapif = () => {
  // Obtener configuración desde el backend
  const { data: configData, isLoading, isError, refetch } = useQuery({
    queryKey: ['configuration'],
    queryFn: getConfiguration,
  });

  const [editableConfig, setEditableConfig] = useState(null);
  const [jsonConfig, setJsonConfig] = useState('');
  const [isJsonValid, setIsJsonValid] = useState(true);
  const [newParams, setNewParams] = useState({}); // Estado para nuevos parámetros

  const [openDialog, setOpenDialog] = useState(false);
  const [currentSection, setCurrentSection] = useState('');
  const [newParamName, setNewParamName] = useState('');
  const [newParamValue, setNewParamValue] = useState('');

  const [openSectionDialog, setOpenSectionDialog] = useState(false);
  const [newSectionName, setNewSectionName] = useState('');
  const [newSectionParams, setNewSectionParams] = useState([{ key: '', value: '' }]);

  useEffect(() => {
    if (configData) {
      setEditableConfig(configData);
    }
  }, [configData]);

  // Mutaciones para actualizar parámetros individuales y reemplazar la configuración
  const updateParamMutation = useMutation({
    mutationFn: updateConfigParam,
    onSuccess: () => refetch(),
  });

  const replaceConfigMutation = useMutation({
    mutationFn: replaceConfiguration,
    onSuccess: () => refetch(),
  });

  const addParamMutation1 = useMutation({
    mutationFn: addParamConfiguration,
    onSuccess: () => {
      refetch();
      setNewParams({});
    },
  });

  const handleValueChange = (path, value) => {
    setEditableConfig((prev) => {
      const newConfig = { ...prev };
      const keys = path.split('.');
      let current = newConfig;
      for (let i = 0; i < keys.length - 1; i++) {
        current = current[keys[i]];
      }
      current[keys[keys.length - 1]] = value;
      return { ...newConfig };
    });
  };

  const handleBlur = (path, value) => {
    updateParamMutation.mutate({ param_path: path, new_value: value });
  };

  // Manejar el evento de presionar Enter
  const handleKeyDown = (event, path, value) => {
    if (event.key === 'Enter') {
      handleBlur(path, value);
    }
  };

  const [jsonError, setJsonError] = useState(""); // Nuevo estado para manejar el error del JSON

  const handleJsonChange = (e) => {
    const value = e.target.value;
    setJsonConfig(value);
  
    try {
      const parsedJson = JSON.parse(value);
      const error = validateJsonConfig(parsedJson);
      
      if (error) {
        setJsonError(error);
      } else {
        setJsonError("");
      }
    } catch (error) {
      setJsonError("Invalid JSON format"); 
    }
  };
  
  const handleJsonSubmit = () => {
    if (jsonError) {
      alert(`Invalid JSON: ${jsonError}`);
      return;
    }
  
    try {
      const parsedJson = JSON.parse(jsonConfig);
      
      replaceConfigMutation.mutate(parsedJson, {
        onSuccess: () => {
          setJsonConfig("");
        }
      });
  
    } catch (error) {
      alert("Invalid JSON format");
    }
  };
  
  

  const validateJsonConfig = (json) => {
    const requiredFields = ["config_name", "version", "description", "settings"];
    const missingFields = requiredFields.filter(field => !(field in json));
  
    if (missingFields.length > 0) {
      return `The following required fields are missing: ${missingFields.join(", ")}`;
    }
  
    if (typeof json.settings !== "object" || Object.keys(json.settings).length === 0) {
      return "The 'settings' field must contain at least one section with settings.";
    }
  
    // Expresión regular para validar *snake_case* (solo minúsculas y _)
    const snakeCaseRegex = /^[a-z0-9_]+$/;
  
    // Función recursiva para validar nombres de los campos
    const validateKeys = (obj, parentKey = "") => {
      for (const key in obj) {
        if (!snakeCaseRegex.test(key)) {
          return `Invalid field name: "${parentKey}${key}". All fields must be in snake_case.`;
        }
        if (typeof obj[key] === "object" && obj[key] !== null) {
          const nestedError = validateKeys(obj[key], `${parentKey}${key}.`);
          if (nestedError) return nestedError;
        }
      }
      return null;
    };
  
    // Validamos los nombres de los campos en toda la estructura
    const snakeCaseError = validateKeys(json);
    if (snakeCaseError) return snakeCaseError;
  
    return null; //  No hay errores
  };
  

  if (isLoading) return <Typography>Loading...</Typography>;
  if (isError) return <Typography>Error loading configuration</Typography>;

  const formatTitle = (text) => {
    return text
      .split(" ")
      .map(word => 
        word.toLowerCase() === "acl"
          ? "ACL" 
          : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
      )
      .join(" ");
  };

  const addParamMutation = useMutation({
    mutationFn: addParamConfiguration,
    onSuccess: () => {
      refetch();
      setOpenDialog(false);
      setNewParamName('');
      setNewParamValue('');
    },
  });

  const handleOpenDialog = (sectionKey) => {
    setCurrentSection(sectionKey);
    setOpenDialog(true);
  };

  const handleCloseDialog = () => {
    setOpenDialog(false);
    setNewParamName('');
    setNewParamValue('');
  };

  const handleAddConfig = () => {
    if (!newParamName || newParamValue === undefined) {
      alert("Debe ingresar un nombre y un valor para la configuración.");
      return;
    }

    const paramPath = `${currentSection}.${newParamName}`;
    addParamMutation.mutate({ param_path: paramPath, new_value: newParamValue });
  };


  const handleOpenSectionDialog = () => setOpenSectionDialog(true);
  const handleCloseSectionDialog = () => {
    setOpenSectionDialog(false);
    setNewSectionName('');
    setNewSectionParams([{ key: '', value: '' }]);
  };

  const handleAddSection = () => {
    if (!newSectionName.trim() || newSectionParams.some(p => !p.key.trim())) {
      alert("Debes ingresar un nombre para la sección y al menos un parámetro.");
      return;
    }

    const categoryValues = newSectionParams.reduce((acc, param) => {
      acc[param.key] = param.value;
      return acc;
    }, {});

    addCategoryMutation.mutate({ category_name: newSectionName, category_values: categoryValues });
  };

  const handleParamChange = (index, field, value) => {
    setNewSectionParams(prevParams => {
      const updatedParams = [...prevParams];
      updatedParams[index][field] = value;
      return updatedParams;
    });
  };

  const handleAddParamField = () => {
    setNewSectionParams(prevParams => [...prevParams, { key: '', value: '' }]);
  };

  const addCategoryMutation = useMutation({
    mutationFn: addCategoryConfiguration,
    onSuccess: () => {
      refetch();
      setOpenSectionDialog(false);
      setNewSectionName('');
      setNewSectionParams([{ key: '', value: '' }]);
    },
  });

  const capitalizeFirstWord = (text) => {
    const words = text.split(" ");
    if (words.length === 0) return text;
    
    words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1).toLowerCase();
    return words.join(" ");
  };

  const handleDownloadJson = () => {
    if (!editableConfig) {
      alert("There is no configuration available for download.");
      return;
    }
  
    const jsonString = JSON.stringify(editableConfig, null, 2);
    const blob = new Blob([jsonString], { type: "application/json" });
    const link = document.createElement("a");
    link.href = URL.createObjectURL(blob);
    link.download = "configuration.json";
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  };


  // Estado para el diálogo de confirmación
  const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
  const [deleteTarget, setDeleteTarget] = useState({ type: '', path: '' });

  // Mutaciones
  const removeParamMutation = useMutation({
    mutationFn: removeConfigParam,
    onSuccess: () => refetch(),
  });

  const removeCategoryMutation = useMutation({
    mutationFn: removeConfigCategory,
    onSuccess: () => refetch(),
  });

  // Función para abrir el diálogo antes de eliminar
  const handleOpenConfirmDialog = (type, path) => {
    setDeleteTarget({ type, path });
    setOpenConfirmDialog(true);
  };

  // Función para cerrar el diálogo
  const handleCloseConfirmDialog = () => {
    setOpenConfirmDialog(false);
    setDeleteTarget({ type: '', path: '' });
  };

  // Función para confirmar eliminación
  const handleConfirmDelete = () => {
    if (deleteTarget.type === 'param') {
      removeParamMutation.mutate({ param_path: deleteTarget.path });
    } else if (deleteTarget.type === 'category') {
      removeCategoryMutation.mutate({ category_name: deleteTarget.path });
    }
    handleCloseConfirmDialog();
  };


  const handleFileUpload = (event) => {
    const file = event.target.files[0];
    if (!file) return;
  
    const reader = new FileReader();
    reader.onload = (e) => {
      try {
        const text = e.target.result;
        const json = JSON.parse(text);
        setJsonConfig(JSON.stringify(json, null, 2));
        setJsonError("");
      } catch (error) {
        setJsonError("Invalid JSON file.");
      }
    };
  
    reader.readAsText(file);
  };
  
  

  return (
    <Grid container spacing={3} marginTop={2}>
      {/* Cuadro principal con datos generales y secciones desplegables */}
      <Grid item xs={12}>
        <Card>
          <CardHeader 
            title={
              <Box display="flex" justifyContent="space-between" alignItems="center">
                <Typography variant="h6">General Configuration</Typography>
                <Button variant="contained" color="secondary" startIcon={<DownloadIcon />} onClick={handleDownloadJson}>
                  Download config
                </Button>
              </Box>
            } 
          />

          <CardContent>
            {/* General Configuration */}
            <Box display="flex" alignItems="center" mb={2} mt={4}>
              <Typography variant="subtitle1" sx={{ flex: 1 }}>Configuration Name</Typography>
              <TextField 
                sx={{ width: '700px' }} 
                value={editableConfig?.configuration_name || ''} 
                onChange={(e) => handleValueChange('configuration_name', e.target.value)} 
                onBlur={(e) => handleBlur('configuration_name', e.target.value)} 
                onKeyDown={(e) => handleKeyDown(e, 'configuration_name', e.target.value)}
              />
            </Box>
            <Box display="flex" alignItems="center" mb={2}>
              <Typography variant="subtitle1" sx={{ flex: 1 }}>Description</Typography>
              <TextField 
                sx={{ width: '700px' }} 
                value={editableConfig?.description || ''} 
                onChange={(e) => handleValueChange('description', e.target.value)} 
                onBlur={(e) => handleBlur('description', e.target.value)} 
                onKeyDown={(e) => handleKeyDown(e, 'description', e.target.value)}
              />
            </Box>
            <Box display="flex" alignItems="center" mb={2}>
              <Typography variant="subtitle1" sx={{ flex: 1 }}>Version</Typography>
              <TextField 
                sx={{ width: '700px' }} 
                value={editableConfig?.version || ''} 
                onChange={(e) => handleValueChange('version', e.target.value)} 
                onBlur={(e) => handleBlur('version', e.target.value)} 
                onKeyDown={(e) => handleKeyDown(e, 'version', e.target.value)}
              />
            </Box>

            {/* Accordions Dinámicos */}
            {editableConfig?.settings &&
              Object.entries(editableConfig.settings).map(([sectionKey, sectionValue]) => (
                  <Accordion key={sectionKey} disableGutters>
                    <AccordionSummary expandIcon={<ExpandMoreIcon />}>
                      <Typography variant="h6">{formatTitle(sectionKey.replace(/_/g, ' '))}</Typography>
                      <IconButton onClick={() => handleOpenConfirmDialog('category', sectionKey)} color="error">
                        <DeleteIcon fontSize="small"/>
                      </IconButton>
                    </AccordionSummary>
                    <AccordionDetails>
                      {sectionValue &&
                        Object.entries(sectionValue).map(([key, value]) => (
                          <Box key={key} display="flex" alignItems="center" mb={1}>
                            <Typography variant="subtitle1" sx={{ flex: 1 }}>
                              {capitalizeFirstWord(key.replace(/_/g, ' '))}
                            </Typography>     
                            <TextField
                              sx={{ width: '700px' }}
                              value={String(value)}
                              onChange={(e) => handleValueChange(`settings.${sectionKey}.${key}`, e.target.value)}
                              onBlur={(e) => handleBlur(`settings.${sectionKey}.${key}`, e.target.value)}
                              onKeyDown={(e) => handleKeyDown(e, `settings.${sectionKey}.${key}`, e.target.value)}
                            />
                            <IconButton onClick={() => handleOpenConfirmDialog('param', `${sectionKey}.${key}`)} color="error">
                              <DeleteIcon fontSize="small"/>
                            </IconButton>
                          </Box>
                        ))}
                        <Box>
                        <Box display="flex" justifyContent="flex-end" mt={3}>
                          <Button variant="contained" onClick={() => handleOpenDialog(sectionKey)}>
                            Add new param
                          </Button>
                        </Box>
                        </Box>
                    </AccordionDetails>
                  </Accordion>
              ))}

              {/* Botón para añadir nueva sección */}
              <Box display="flex" justifyContent="flex-end" mt={5}>
                <Button variant="contained" color="primary" onClick={handleOpenSectionDialog}>
                  Add New Configuration Section
                </Button>
              </Box>
          </CardContent>
        </Card>

        {/* 🔹 Dialog para agregar nuevo parámetro 🔹 */}
        <Dialog open={openDialog} onClose={handleCloseDialog} fullWidth maxWidth="sm">
          <DialogTitle>Add new param</DialogTitle>
          <DialogContent>
            <TextField
              label="Param name"
              fullWidth
              sx={{ mt: 2 }}
              value={newParamName}
              onChange={(e) => setNewParamName(e.target.value)}
            />
            <TextField
              label="Valor"
              fullWidth
              sx={{ mt: 2 }}
              value={newParamValue}
              onChange={(e) => setNewParamValue(e.target.value)}
            />
          </DialogContent>
          <DialogActions>
            <Button onClick={handleCloseDialog} color="secondary">Cancel</Button>
            <Button onClick={handleAddConfig} variant="contained" color="primary">Save</Button>
          </DialogActions>
        </Dialog>

        {/* Dialog para añadir nueva sección */}
        <Dialog open={openSectionDialog} onClose={handleCloseSectionDialog} fullWidth maxWidth="sm">
          <DialogTitle>Add new section</DialogTitle>
          <DialogContent>
            <TextField
              label="Section name"
              fullWidth
              sx={{ mt: 2 }}
              value={newSectionName}
              onChange={(e) => setNewSectionName(e.target.value)}
            />

            {newSectionParams.map((param, index) => (
              <Box key={index} display="flex" gap={2} mt={2}>
                <TextField
                  label="Param name"
                  fullWidth
                  value={param.key}
                  onChange={(e) => handleParamChange(index, 'key', e.target.value)}
                />
                <TextField
                  label="Value"
                  fullWidth
                  value={param.value}
                  onChange={(e) => handleParamChange(index, 'value', e.target.value)}
                />
              </Box>
            ))}

            <Button onClick={handleAddParamField} sx={{ mt: 2 }}>Add Param </Button>
          </DialogContent>
          <DialogActions>
            <Button onClick={handleCloseSectionDialog} color="secondary">Cancel</Button>
            <Button onClick={handleAddSection} variant="contained" color="primary">Save</Button>
          </DialogActions>
        </Dialog>

        {/* Diálogo de Confirmación */}
        <Dialog open={openConfirmDialog} onClose={handleCloseConfirmDialog} fullWidth maxWidth="sm">
          <DialogTitle>Confirm Deletion</DialogTitle>
          <DialogContent>
            <Typography>
              Are you sure you want to delete {deleteTarget.type === 'category' ? "the category" : "the parameter"}: <b>{deleteTarget.path}</b>?
            </Typography>
          </DialogContent>
          <DialogActions>
            <Button onClick={handleCloseConfirmDialog} color="secondary">Cancel</Button>
            <Button onClick={handleConfirmDelete} variant="contained" color="error">Delete</Button>
          </DialogActions>
        </Dialog>
      </Grid>

      {/* Upload JSON Configuration */}
      <Grid item xs={12}>
        <Card>
          <CardHeader 
            title={
              <Box display="flex" justifyContent="space-between" alignItems="center">
                <Typography variant="h6">Upload New Configuration</Typography>
                {/* Botón para seleccionar archivo JSON alineado a la derecha */}
                <label htmlFor="file-upload">
                  <input
                    type="file"
                    accept=".json"
                    style={{ display: "none" }}
                    id="file-upload"
                    onChange={handleFileUpload}
                  />
                  <Button component="span" variant="contained">
                    Select JSON File
                  </Button>
                </label>
              </Box>
            } 
          />
          <CardContent>
            <TextField
              fullWidth
              label="Paste JSON here"
              multiline
              rows={8}
              variant="outlined"
              value={jsonConfig}
              onChange={handleJsonChange}
              error={!!jsonError}
              helperText={jsonError}
            />
            <Button variant="contained" fullWidth sx={{ mt: 2 }} onClick={handleJsonSubmit}>
              Replace Configuration
            </Button>
          </CardContent>
        </Card>
      </Grid>

    </Grid>
  );
};

export default ConfigCapif;
+565 −0

File added.

Preview size limit exceeded, changes collapsed.

+181 −1

File changed.

Preview size limit exceeded, changes collapsed.

+5 −0
Original line number Diff line number Diff line
@@ -24,6 +24,11 @@ const navigation = () => {
      title: 'Users',
      icon: 'mdi:baseline-people',
      path: '/users'
    },
    {
      title: 'Configuration',
      icon: 'mdi:baseline-people',
      path: '/configuration'
    }
    
    // ,
Loading