1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-23 22:50:09 +03:00

Revert "F OpenNebula/one#6121: Added new multichart (#2703)"

This reverts commit 87a009f165c55b953cbee961e84debec8ed41c4f.
This commit is contained in:
Tino Vázquez 2023-08-30 14:30:02 +02:00
parent cde8501eab
commit addb5edc33
22 changed files with 103 additions and 2575 deletions

File diff suppressed because it is too large Load Diff

View File

@ -36,7 +36,6 @@
"eslint-config-opennebula": "2.0.2",
"eslint-import-resolver-alias": "1.1.2",
"eslint-import-resolver-webpack": "0.13.0",
"file-loader": "5.0.0",
"opennebula-generatepotfile": "1.0.0",
"opennebula-potojson": "1.0.0",
"react-refresh": "0.10.0",
@ -65,10 +64,9 @@
"@loadable/server": "5.15.1",
"@loadable/webpack-plugin": "5.15.1",
"@mui/lab": "5.0.0-alpha.59",
"@mui/material": "5.4.1",
"@mui/material": "5.1.0",
"@mui/styles": "5.1.0",
"@mui/system": "5.4.1",
"@mui/x-data-grid": "5.0.1",
"@mui/system": "5.1.0",
"@reduxjs/toolkit": "1.7.1",
"atob": "2.1.2",
"axios": "0.25.0",
@ -111,8 +109,8 @@
"notistack": "2.0.3",
"opennebula-guacamole": "1.0.0",
"opennebula-wmks": "2.1.4",
"papaparse": "5.4.1",
"path": "0.12.7",
"process": "0.11.10",
"prop-types": "15.7.2",
"qrcode": "1.4.4",
"react": "17.0.2",
@ -129,7 +127,6 @@
"react-table": "7.7.0",
"react-transition-group": "4.4.1",
"react-virtual": "2.7.1",
"recharts": "2.7.3",
"redux": "4.1.1",
"redux-thunk": "2.3.0",
"rimraf": "3.0.2",

View File

@ -1,83 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { CartesianGrid } from 'recharts'
import { Component, Fragment } from 'react'
/**
* Generates a color based on the metric, datasetId, and theme type.
*
* @function
* @param {string} metric - The metric for which the color is generated.
* @param {object} metricHues - Object containing hue values for different metrics.
* @param {number} datasetId - The ID of the dataset.
* @returns {string} The generated color in HSL format.
*/
export const generateColorByMetric = (metric, metricHues, datasetId) => {
const baseHue = metricHues[metric] || 0
// Using datasetId as seed to generate a unique hue offset
const hueOffset = (datasetId * 1327) % 360 // 1327 is just a random prime number
const baseSaturation = 90
const baseLightness = 60
const hue = (baseHue + hueOffset) % 360
const saturation = `${baseSaturation}%`
const lightness = `${baseLightness}%`
return `hsl(${hue}, ${saturation}, ${lightness})`
}
/**
* Returns a React component containing SVG definitions and a CartesianGrid for a chart.
*
* @function
* @param {Array<string>} metrics - List of metrics.
* @param {number} datasetId - The ID of the dataset.
* @param {object} metricHues - Object containing hue values for different metrics.
* @returns {Component} A React component with SVG definitions and a CartesianGrid.
*/
export const GetChartDefs = (metrics, datasetId, metricHues) => (
<Fragment key={`defs-${datasetId}`}>
<defs>
{metrics.map((metric) => {
const color = generateColorByMetric(metric, metricHues, datasetId)
return (
<linearGradient
key={metric}
id={`color${metric}-${datasetId}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={color} stopOpacity={0.9} />
<stop offset="95%" stopColor={color} stopOpacity={0.2} />
</linearGradient>
)
})}
</defs>
<CartesianGrid stroke={'#ccc'} strokeDasharray="4 4" strokeOpacity={0.1} />
</Fragment>
)
GetChartDefs.propTypes = {
metrics: PropTypes.arrayOf(PropTypes.string).isRequired,
metricHues: PropTypes.objectOf(PropTypes.number).isRequired,
datasetId: PropTypes.number.isRequired,
}

View File

@ -1,199 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/**
* Processes data for rendering on a chart.
*
* This function restructures the input data. For each unique grouping attribute,
* it creates an object that sums metric values across all datasets. If a dataset doesn't have
* a metric value for a particular group, it defaults to 0.
*
* @function
* @param {Array<string>} uniqueGroups - An array of unique groups representing different data points or entities.
* @param {Array<object>} datasets - An array of datasets.
* @param {Array<number>} visibleDatasetIDs - An array of dataset ID's to display.
* @param {string} groupBy - The attribute by which data should be grouped (e.g., 'NAME', 'OID').
* @returns {Array<object>} An array of processed data items, each structured with properties for every metric from every dataset.
*/
export const processDataForChart = (
uniqueGroups,
datasets,
visibleDatasetIDs,
groupBy
) => {
const visibleDatasets = datasets.filter((dataset) =>
visibleDatasetIDs.includes(dataset.id)
)
return uniqueGroups.map((group) => {
const item = { [groupBy]: group }
visibleDatasets.forEach((dataset) => {
const matchingItem = dataset.data.find((d) => d[groupBy] === group)
dataset.metrics.forEach((metric) => {
item[`${metric.key}-${dataset.id}`] = matchingItem
? matchingItem[metric.key]
: 0
})
})
return item
})
}
/**
* Recursively searches for the first array of objects in the given object.
* Used with all pool-like API requests to find the data array dynamically.
*
* @param {object} obj - The object to search within.
* @returns {Array|null} - The found array or null if not found.
*/
const findFirstArray = (obj) => {
for (const [, value] of Object.entries(obj)) {
if (
Array.isArray(value) &&
value.length > 0 &&
typeof value[0] === 'object'
) {
return value
}
if (typeof value === 'object') {
const result = findFirstArray(value)
if (result) return result
}
}
return null
}
/**
* Transforms the API response into the desired dataset format.
*
* @param {object} apiResponse - The API response to process.
* @param {object} keyMap - An object that maps the keys in the API response to the desired output keys.
* @param {Array} metricKeys - An array of keys to aggregate for the metrics.
* @param {Function} labelingFunction - A function to generate the label for the dataset.
* @returns {object} - The transformed dataset.
*/
export const transformApiResponseToDataset = (
apiResponse,
keyMap,
metricKeys,
labelingFunction
) => {
const dataArray = findFirstArray(apiResponse)
const transformedRecords = dataArray.map((record) => {
const transformedRecord = {}
Object.keys(keyMap).forEach((key) => {
transformedRecord[keyMap[key]] = record[key]
})
return transformedRecord
})
const metrics = metricKeys.map((key) => {
const total = transformedRecords.reduce(
(acc, record) => acc + parseFloat(record[key] || 0),
0
)
return { key: key, value: total }
})
let label = 'N/A'
if (labelingFunction) {
try {
label = labelingFunction(transformedRecords[0])
} catch (error) {
// Handle this sometime
}
return {
id: generateDatasetId({
data: transformedRecords,
metrics: metrics,
label: label,
}),
data: transformedRecords,
metrics: metrics,
label: label,
}
}
}
/**
* Filters a processed dataset based on a custom filter function and recalculates the label.
*
* @param {object} dataset - The processed dataset.
* @param {Function} filterFn - A custom function that determines which records to include.
* @param {Function} labelingFunction - A function to generate the label for the subset.
* @returns {object} - A subset of the dataset with a recalculated label.
*/
export const filterDataset = (dataset, filterFn, labelingFunction) => {
const { data, metrics } = dataset
const filteredData = data.filter(filterFn)
const filteredMetrics = metrics.map((metric) => {
const total = filteredData.reduce(
(acc, record) => acc + parseFloat(record[metric.key] || 0),
0
)
return { key: metric.key, value: total }
})
let label = 'N/A'
if (labelingFunction && filteredData.length > 0) {
try {
label = labelingFunction(filteredData[0])
} catch (error) {
// Handle this sometime
}
return {
id: generateDatasetId({
data: filteredData,
metrics: metrics,
label: label,
}),
data: filteredData,
metrics: filteredMetrics,
label: label,
}
}
}
const generateDatasetId = (dataset) => {
const dataLength = dataset.data.length
if (dataLength === 0) return generateChecksum('empty')
const firstRecord = JSON.stringify(dataset.data[0])
const middleRecord = JSON.stringify(dataset.data[Math.floor(dataLength / 2)])
const lastRecord = JSON.stringify(dataset.data[dataLength - 1])
const combinedString = firstRecord + middleRecord + lastRecord + dataLength // This will not collide
return generateChecksum(combinedString)
}
const generateChecksum = (input) => {
let sum = 0
for (let i = 0; i < input.length; i++) {
sum += input.charCodeAt(i)
}
return sum * 1327 // Random prime number
}

View File

@ -1,47 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import Papa from 'papaparse'
/**
* Exports the provided data as a CSV file.
*
* @function
* @param {Array<object>} data - An array of datasets containing data to be exported.
* @returns {Error} - Returns the error to the Exporter component to enqueue it.
*/
export const exportDataToCSV = (data) => {
try {
const csvData = data.flatMap((item) => item.data)
const csv = Papa.unparse(csvData)
const blob = new Blob([csv], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.setAttribute('hidden', '')
a.setAttribute('href', url)
a.setAttribute(
'download',
data[0]?.label
? `${data[0].label.replace(/ /g, '')}_report.csv`
: 'one_report.csv'
)
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch (error) {
return error
}
}

View File

@ -1,121 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import ReactDOMServer from 'react-dom/server'
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Box,
} from '@mui/material'
import logo from 'client/assets/images/logo.png'
/**
* Generates a printable PDF report from the provided data.
*
* @function
* @param {Array<object>} data - The data to be exported to PDF.
* @returns {Error} - Returns the error to the Exporter component to enqueue it.
*/
export const exportDataToPDF = (data) => {
try {
const rows = data.flatMap((item) => item.data)
const tableComponent = (
<Box
style={{
padding: '20px',
fontFamily: 'Arial, sans-serif',
backgroundColor: '#f9f9f9',
}}
>
<img
src={logo}
alt="Logo"
style={{
maxWidth: '150px',
maxHeight: '50px',
marginBottom: '10px',
marginLeft: '0px',
}}
/>
<h2 style={{ textAlign: 'center', margin: '20px 0', color: '#333' }}>
Data Report
</h2>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
{Object.keys(rows[0]).map((header) => (
<TableCell
key={header}
style={{
backgroundColor: '#f5f5f5',
fontWeight: 'bold',
color: '#555',
border: '1px solid #333',
}}
>
{header}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, rowIndex) => (
<TableRow key={rowIndex}>
{Object.values(row).map((value, valueIndex) => (
<TableCell
key={valueIndex}
style={{ color: '#666', border: '1px solid #333' }}
>
{String(value)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)
const tableHTML = ReactDOMServer.renderToString(tableComponent)
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
document.body.appendChild(iframe)
iframe.contentDocument.write(
'<html><head><title>Accounting Report</title></head><body>'
)
iframe.contentDocument.write(tableHTML)
iframe.contentDocument.write('</body></html>')
iframe.contentDocument.close()
iframe.contentWindow.print()
setTimeout(() => {
document.body.removeChild(iframe)
}, 1000)
} catch (error) {
return error
}
}

View File

@ -1,36 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import {
processDataForChart,
transformApiResponseToDataset,
filterDataset,
} from 'client/components/Charts/MultiChart/helpers/scripts/dataProcessing'
import {
generateColorByMetric,
GetChartDefs,
} from 'client/components/Charts/MultiChart/helpers/scripts/chartDefs'
import { exportDataToPDF } from 'client/components/Charts/MultiChart/helpers/scripts/exportPDF'
import { exportDataToCSV } from 'client/components/Charts/MultiChart/helpers/scripts/exportCSV'
export {
processDataForChart,
transformApiResponseToDataset,
filterDataset,
generateColorByMetric,
GetChartDefs,
exportDataToPDF,
exportDataToCSV,
}

View File

@ -1,194 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import React from 'react'
import PropTypes from 'prop-types'
import {
BarChart,
LineChart,
AreaChart,
XAxis,
YAxis,
Tooltip,
Bar,
Legend,
Line,
Area,
ResponsiveContainer,
} from 'recharts'
import { DataGridTable } from 'client/components/Tables'
import { CustomTooltip } from 'client/components/Tooltip'
import { generateColorByMetric } from 'client/components/Charts/MultiChart/helpers/scripts'
const CHART_TYPES = {
BAR: 'bar',
LINE: 'line',
AREA: 'area',
TABLE: 'table',
}
const ChartComponents = {
[CHART_TYPES.BAR]: BarChart,
[CHART_TYPES.LINE]: LineChart,
[CHART_TYPES.AREA]: AreaChart,
[CHART_TYPES.TABLE]: DataGridTable,
}
const ChartElements = {
[CHART_TYPES.BAR]: Bar,
[CHART_TYPES.LINE]: Line,
[CHART_TYPES.AREA]: Area,
}
/**
* Renders a chart based on the provided type and data.
*
* @param {object} props - The properties for the component.
* @param {'bar' | 'line' | 'area' | 'table'} props.chartType - The type of chart to render.
* @param {Array} props.datasets - The datasets to be used for the chart.
* @param {object} props.selectedMetrics - The metrics selected for display.
* @param {Function} props.customChartDefs - Custom definitions for the chart.
* @param {Array} props.paginatedData - The paginated data for the chart.
* @param {Array} props.tableColumns - The columns for the table chart type.
* @param {Function} props.humanReadableMetric - Function to convert metric keys to human-readable format.
* @param {string} props.groupBy - The variable to group data under.
* @param {object} props.metricHues - Object containing hue values for different metrics.
* @returns {React.Component} The rendered chart component.
*/
export const ChartRenderer = ({
chartType,
datasets,
selectedMetrics,
customChartDefs,
paginatedData,
tableColumns,
humanReadableMetric,
groupBy,
metricHues,
}) => {
const ChartComponent = ChartComponents[chartType]
const ChartElement = ChartElements[chartType]
return (
<ResponsiveContainer height="100%" width="100%">
{chartType === CHART_TYPES.TABLE ? (
<DataGridTable
columns={tableColumns}
data={datasets}
selectedItems={selectedMetrics}
/>
) : (
<ChartComponent
data={paginatedData}
barCategoryGap={20}
style={!datasets.length ? { pointerEvents: 'none' } : {}}
padding={{ top: 0, right: 60, bottom: 0, left: 60 }}
>
{datasets.map((dataset) =>
customChartDefs(
dataset.metrics.map((m) => m.key),
dataset.id,
metricHues
)
)}
<XAxis interval={0} dataKey={groupBy} />
<YAxis />
<Tooltip
content={
<CustomTooltip
labels={datasets.map((ds) => ds.label)}
generateColor={generateColorByMetric}
formatMetric={humanReadableMetric}
metricHues={metricHues}
/>
}
cursor="pointer"
/>
<Legend
formatter={(value) => {
const [metric, datasetId] = value.split('-')
const currentDataset = datasets.find(
(ds) => ds.id === parseInt(datasetId, 10)
)
const datasetLabel = currentDataset.label
const lastSelectedMetric = [...currentDataset.metrics]
.reverse()
.find((m) => selectedMetrics[m.key])
if (lastSelectedMetric && metric === lastSelectedMetric.key) {
return `${humanReadableMetric(metric)} (${datasetLabel})`
}
return humanReadableMetric(metric)
}}
wrapperStyle={{
wordWrap: 'break-word',
maxWidth: '100%',
}}
/>
{datasets.map((dataset) =>
dataset.metrics.map((metric) =>
selectedMetrics[metric.key] ? (
<ChartElement
key={`${metric.key}-${dataset.id}`}
type="monotone"
dataKey={`${metric.key}-${dataset.id}`}
fill={`url(#color${metric.key}-${dataset.id})`}
name={metric.name}
animationDuration={500}
{...(chartType === 'area' && {
fillOpacity: 0.5,
stroke: 'transparent',
})}
{...(chartType === 'line' && {
strokeWidth: 3,
activeDot: {
r: 8,
fill: `url(#color${metric.key}-${dataset.id})`,
stroke: 'white',
strokeWidth: 2,
},
stroke: `url(#color${metric.key}-${dataset.id})`,
})}
/>
) : null
)
)}
</ChartComponent>
)}
</ResponsiveContainer>
)
}
ChartRenderer.propTypes = {
chartType: PropTypes.oneOf(['bar', 'line', 'area', 'table']).isRequired,
datasets: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedMetrics: PropTypes.object.isRequired,
customChartDefs: PropTypes.func.isRequired,
paginatedData: PropTypes.arrayOf(PropTypes.object).isRequired,
tableColumns: PropTypes.arrayOf(PropTypes.object),
humanReadableMetric: PropTypes.func.isRequired,
groupBy: PropTypes.string.isRequired,
metricHues: PropTypes.objectOf(PropTypes.number).isRequired,
}
ChartRenderer.defaultProps = {
groupBy: 'NAME',
}

View File

@ -1,117 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { Button, Menu, MenuItem, Box } from '@mui/material'
import { Download } from 'iconoir-react'
import {
exportDataToCSV,
exportDataToPDF,
} from 'client/components/Charts/MultiChart/helpers/scripts'
import { useGeneralApi } from 'client/features/General'
/**
* Renders a button that provides export options for data.
*
* @param {object} props - The properties for the component.
* @param {Array} props.data - The data to be exported.
* @param {Array} props.exportOptions - The available export options.
* @param {object} props.exportHandlers - The handlers for each export type.
* @returns {React.Component} The rendered export button component.
*/
export const ExportButton = ({ data, exportOptions, exportHandlers }) => {
const [anchorEl, setAnchorEl] = useState(null)
const { enqueueError } = useGeneralApi()
const handleMenuOpen = (event) => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
const handleExport = (type) => {
if (exportHandlers[type]) {
const error = exportHandlers[type](data)
if (error) {
enqueueError(
'Error exporting data to ' + type.toUpperCase() + ': ' + error.message
)
}
}
handleMenuClose()
}
const noData =
!data ||
data.length === 0 ||
data.every((item) => !item.data || item.data.length === 0)
return (
<Box>
<Button
endIcon={<Download />}
onClick={handleMenuOpen}
disabled={noData}
variant="outlined"
sx={{
padding: '3px 17px',
transition: 'all 0.1s ease',
'&:hover': {
borderColor: '#2a2a2a',
},
}}
>
Export
</Button>
<Menu
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
{exportOptions.map((option) => (
<MenuItem key={option.type} onClick={() => handleExport(option.type)}>
{option.label}
</MenuItem>
))}
</Menu>
</Box>
)
}
ExportButton.propTypes = {
data: PropTypes.arrayOf(PropTypes.object).isRequired,
exportOptions: PropTypes.arrayOf(
PropTypes.shape({
type: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})
),
exportHandlers: PropTypes.objectOf(PropTypes.func),
}
ExportButton.defaultProps = {
exportOptions: [
{ type: 'csv', label: 'Export as CSV' },
{ type: 'pdf', label: 'Export as PDF' },
],
exportHandlers: {
csv: (data) => exportDataToCSV(data),
pdf: (data) => exportDataToPDF(data),
},
}

View File

@ -1,170 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import React from 'react'
import PropTypes from 'prop-types'
import { NavArrowRight, NavArrowLeft, Filter } from 'iconoir-react'
import {
Box,
IconButton,
FormControl,
Select,
MenuItem,
Checkbox,
ListItemText,
} from '@mui/material'
/**
* NavigationController component for navigating through items and filtering them.
*
* @param {object} props - Props
* @param {Function} props.onPrev - Callback function when the previous button is clicked.
* @param {Function} props.onNext - Callback function when the next button is clicked.
* @param {boolean} props.isPrevDisabled - Determines if the previous button is disabled.
* @param {boolean} props.isNextDisabled - Determines if the next button is disabled.
* @param {Array} props.selectedItems - List of currently selected items.
* @param {Array} props.items - List of all available items.
* @param {Function} props.setSelectedItems - Callback function to set the selected items.
* @param {boolean} props.isFilterDisabled - Determines if the filter is disabled.
* @param {boolean} props.isPaginationDisabled - Determines if the pagination is disabled.
* @param {object} props.styles - Custom styles for the component.
* @returns {React.Component} NavigationController component.
*/
export const NavigationController = ({
onPrev,
onNext,
isPrevDisabled,
isNextDisabled,
selectedItems,
items,
setSelectedItems,
isFilterDisabled,
isPaginationDisabled,
styles,
}) => (
<Box sx={{ ...styles.container, display: 'inline-flex' }}>
<BoxedIcon disabled={isFilterDisabled}>
<FormControl size="small" sx={styles.formControl}>
<Select
multiple
value={selectedItems}
onChange={(event) => setSelectedItems(event.target.value)}
IconComponent={Filter}
sx={styles.select}
renderValue={(selected) => ''}
disabled={isFilterDisabled}
>
{items.map((item) => (
<MenuItem key={item} value={item}>
<Checkbox checked={selectedItems.includes(item)} />
<ListItemText primary={item} />
</MenuItem>
))}
</Select>
</FormControl>
</BoxedIcon>
<Box sx={styles.divider} />
<BoxedIcon
onClick={onPrev}
disabled={isPrevDisabled || isPaginationDisabled}
>
<NavArrowLeft />
</BoxedIcon>
<Box sx={styles.divider} />
<BoxedIcon
onClick={onNext}
disabled={isNextDisabled || isPaginationDisabled}
>
<NavArrowRight />
</BoxedIcon>
</Box>
)
const BoxedIcon = ({ children, ...props }) => (
<Box
component={IconButton}
sx={{
width: 40,
height: 40,
display: 'flex',
alignItems: 'center',
border: 'none',
padding: 0,
justifyContent: 'center',
'&.Mui-focusVisible': {
outline: 'none',
boxShadow: 'none',
},
'&:hover': {
backgroundColor: 'transparent',
},
}}
{...props}
>
{children}
</Box>
)
NavigationController.propTypes = {
onPrev: PropTypes.func.isRequired,
onNext: PropTypes.func.isRequired,
isPrevDisabled: PropTypes.bool.isRequired,
isNextDisabled: PropTypes.bool.isRequired,
selectedItems: PropTypes.array.isRequired,
items: PropTypes.array,
setSelectedItems: PropTypes.func.isRequired,
isFilterDisabled: PropTypes.bool.isRequired,
isPaginationDisabled: PropTypes.bool.isRequired,
styles: PropTypes.object,
}
BoxedIcon.propTypes = {
children: PropTypes.node.isRequired,
}
NavigationController.defaultProps = {
styles: {
container: {
display: 'flex',
alignItems: 'center',
border: 'none',
borderBottom: 'none',
borderRadius: '2px 2px 0 0',
},
formControl: {
minWidth: 40,
'& .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
},
select: {
borderRadius: 0,
paddingRight: 0,
border: 'none',
},
divider: {
width: 1,
height: '100%',
},
},
items: [],
}

View File

@ -1,20 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ChartRenderer } from 'client/components/Charts/MultiChart/helpers/subComponents/ChartRenderer'
import { NavigationController } from 'client/components/Charts/MultiChart/helpers/subComponents/NavigationController'
import { ExportButton } from 'client/components/Charts/MultiChart/helpers/subComponents/Exporter'
export { ChartRenderer, NavigationController, ExportButton }

View File

@ -1,252 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import React, { useState, useMemo, useEffect } from 'react'
import { Box } from '@mui/material'
import {
ChartRenderer,
NavigationController,
ExportButton,
} from 'client/components/Charts/MultiChart/helpers/subComponents'
import { LoadingDisplay } from 'client/components/LoadingState'
import {
processDataForChart,
GetChartDefs,
} from 'client/components/Charts/MultiChart/helpers/scripts'
/**
* @param {object} props - Component properties.
* @param {Array} props.datasets - Array of datasets to visualize.
* @param {Array} props.visibleDatasets - Array of visible dataset IDs (subset of datasets).
* @param {Array} props.xAxisLabels - Array of unique names for the X-axis.
* @param {string} props.chartType - Type of chart to render ('bar', 'line', etc.).
* @param {object} props.selectedMetrics - Object containing selected metrics (e.g., "cpuHours").
* @param {string} [props.error] - Error message, if any.
* @param {string} props.groupBy - Key to group X values by.
* @param {number} props.ItemsPerPage - Number of items to display per page.
* @param {Array} props.tableColumns - Array of table column configurations.
* @param {Function} props.customChartDefs - Function to generate custom chart definitions.
* @param {object} [props.metricNames={}] - Object mapping metric keys to human-readable names.
* @param {object} props.metricHues - Object containing hue values for different metrics.
* @returns {React.Component} MultiChart component.
*/
const MultiChart = ({
datasets,
visibleDatasets,
xAxisLabels: passedxAxisLabels,
chartType,
selectedMetrics: passedSelectedMetrics,
error,
ItemsPerPage,
tableColumns,
customChartDefs,
metricNames,
groupBy,
metricHues: passedMetricHues,
}) => {
const [currentPage, setCurrentPage] = useState(0)
const [selectedXValues, setSelectedXValues] = useState([])
const [isFilterDisabled, setIsFilterDisabled] = useState(true)
const [isPaginationDisabled, setIsPaginationDisabled] = useState(true)
const xAxisLabels = useMemo(() => {
if (passedxAxisLabels && passedxAxisLabels.length > 0) {
return passedxAxisLabels
}
return [
...new Set(
datasets.flatMap((dataset) => dataset.data.map((item) => item[groupBy]))
),
]
}, [datasets, groupBy, passedxAxisLabels])
const selectedMetrics = useMemo(() => {
if (
passedSelectedMetrics &&
Object.keys(passedSelectedMetrics).length > 0
) {
return passedSelectedMetrics
}
return datasets.reduce((acc, dataset) => {
dataset.metrics.forEach((metric) => {
acc[metric.key] = true
})
return acc
}, {})
}, [datasets, passedSelectedMetrics])
const metricHues = useMemo(() => {
if (passedMetricHues && Object.keys(passedMetricHues).length > 0) {
return passedMetricHues
}
const allMetrics = [
...new Set(
datasets.flatMap((dataset) =>
dataset.metrics.map((metric) => metric.key)
)
),
]
const hueStep = 360 / allMetrics.length
return allMetrics.reduce((acc, metric, index) => {
acc[metric] = index * hueStep
return acc
}, {})
}, [datasets, passedMetricHues])
const visibleDatasetIDs = useMemo(() => {
if (visibleDatasets && visibleDatasets.length > 0) {
return visibleDatasets
}
return datasets.map((dataset) => dataset.id)
}, [datasets, visibleDatasets])
const noDataAvailable = visibleDatasetIDs.length === 0
const mergedDataForXAxis = processDataForChart(
xAxisLabels,
datasets,
visibleDatasetIDs,
groupBy
)
const humanReadableMetric = (key) => metricNames[key] || key
useEffect(() => {
setSelectedXValues(xAxisLabels)
}, [xAxisLabels])
useEffect(() => {
setIsFilterDisabled(visibleDatasetIDs.length === 0)
}, [visibleDatasetIDs])
useEffect(() => {
setIsPaginationDisabled(
selectedXValues.length <= ItemsPerPage || isFilterDisabled
)
}, [selectedXValues, isFilterDisabled])
const paginatedData = useMemo(() => {
const filteredData = mergedDataForXAxis.filter((item) =>
selectedXValues.includes(item[groupBy])
)
const start = currentPage * ItemsPerPage
const end = start + ItemsPerPage
return filteredData.slice(start, end)
}, [mergedDataForXAxis, currentPage, selectedXValues])
return (
<Box width="100%" height="100%" display="flex" flexDirection="column">
<Box display="flex" justifyContent="space-between">
{chartType !== 'table' && (
<Box flex={1}>
<NavigationController
onPrev={() => setCurrentPage((prev) => Math.max(prev - 1, 0))}
onNext={() =>
setCurrentPage((prev) =>
Math.min(
prev + 1,
Math.ceil(selectedXValues.length / ItemsPerPage) - 1
)
)
}
isPrevDisabled={currentPage === 0}
isNextDisabled={
currentPage ===
Math.ceil(selectedXValues.length / ItemsPerPage) - 1
}
selectedItems={selectedXValues}
items={xAxisLabels}
setSelectedItems={setSelectedXValues}
isFilterDisabled={isFilterDisabled}
isPaginationDisabled={isPaginationDisabled}
/>
</Box>
)}
<Box flex={1} display="flex" justifyContent="flex-end">
<ExportButton data={datasets} />
</Box>
</Box>
<Box flex={1} mt={-1}>
{error || noDataAvailable ? (
<LoadingDisplay error={error} />
) : (
<ChartRenderer
chartType={chartType}
datasets={datasets}
selectedMetrics={selectedMetrics}
customChartDefs={customChartDefs}
tableColumns={tableColumns}
paginatedData={paginatedData}
humanReadableMetric={humanReadableMetric}
groupBy={groupBy}
metricHues={metricHues}
/>
)}
</Box>
</Box>
)
}
MultiChart.propTypes = {
datasets: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
data: PropTypes.arrayOf(PropTypes.object.isRequired).isRequired,
metrics: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
})
).isRequired,
label: PropTypes.string.isRequired,
})
).isRequired,
visibleDatasets: PropTypes.arrayOf(PropTypes.number).isRequired,
xAxisLabels: PropTypes.arrayOf(PropTypes.string),
chartType: PropTypes.oneOf(['bar', 'line', 'area', 'table']).isRequired,
selectedMetrics: PropTypes.object,
error: PropTypes.string,
groupBy: PropTypes.string,
ItemsPerPage: PropTypes.number.isRequired,
tableColumns: PropTypes.arrayOf(PropTypes.object),
customChartDefs: PropTypes.func.isRequired,
metricNames: PropTypes.object,
metricHues: PropTypes.objectOf(PropTypes.number).isRequired,
}
MultiChart.defaultProps = {
visibleDatasets: [],
chartType: 'bar',
ItemsPerPage: 10,
groupBy: 'ID',
customChartDefs: GetChartDefs,
metricNames: {},
metricHues: {},
}
export default MultiChart

View File

@ -16,6 +16,5 @@
import CircleChart from 'client/components/Charts/CircleChart'
import SingleBar from 'client/components/Charts/SingleBar'
import Chartist from 'client/components/Charts/Chartist'
import MultiChart from 'client/components/Charts/MultiChart/index'
export { CircleChart, SingleBar, Chartist, MultiChart }
export { CircleChart, SingleBar, Chartist }

View File

@ -1,53 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import React from 'react'
import { Box, Typography } from '@mui/material'
import { InfoEmpty, CloudError } from 'iconoir-react'
/**
* Renders a display message based on the presence of an error.
*
* @param {object} props - The properties for the component.
* @param {boolean} props.error - Indicates if there was an error fetching data.
* @returns {React.Component} The rendered loading display component.
*/
export const LoadingDisplay = ({ error }) => {
const displayMessage = error ? 'Error fetching data' : 'No data available'
const DisplayIcon = error ? CloudError : InfoEmpty
return (
<Box
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
height="500px"
borderRadius={4}
boxShadow={2}
padding={3}
>
<DisplayIcon style={{ fontSize: 40 }} />
<Typography variant="h6" color="textSecondary" marginTop={2}>
{displayMessage}
</Typography>
</Box>
)
}
LoadingDisplay.propTypes = {
error: PropTypes.bool,
}

View File

@ -1,17 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
export { LoadingDisplay } from './LoadingDisplay'

View File

@ -1,66 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import React from 'react'
import PropTypes from 'prop-types'
import { DataGrid } from '@mui/x-data-grid'
import { Box } from '@mui/material'
/**
* Renders a data grid table using the provided data and columns.
*
* @param {object} props - The properties for the component.
* @param {Array} props.data - The data to be displayed in the table.
* @param {Array} props.columns - The columns configuration for the table.
* @returns {React.Component} The rendered data grid table component.
*/
const DataGridTable = ({ data, columns }) => {
const flattenedData = data.flatMap((dataset) => dataset.data)
return (
<Box
style={{
height: '100%',
width: '100%',
overflow: 'hidden',
paddingBottom: '54px',
paddingTop: '18px',
}}
>
<DataGrid
rows={flattenedData.map((row, index) => ({ ...row, id: index }))}
columns={columns}
rowsPerPageOptions={[25, 50, 100]}
/>
</Box>
)
}
DataGridTable.propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
data: PropTypes.arrayOf(PropTypes.object).isRequired,
})
).isRequired,
columns: PropTypes.arrayOf(
PropTypes.shape({
field: PropTypes.string.isRequired,
headerName: PropTypes.string.isRequired,
width: PropTypes.number,
})
).isRequired,
}
export default DataGridTable

View File

@ -17,7 +17,6 @@ import AllImagesTable from 'client/components/Tables/AllImages'
import BackupsTable from 'client/components/Tables/Backups'
import ClustersTable from 'client/components/Tables/Clusters'
import DatastoresTable from 'client/components/Tables/Datastores'
import DataGridTable from 'client/components/Tables/DataGrid'
import DockerHubTagsTable from 'client/components/Tables/DockerHubTags'
import EnhancedTable from 'client/components/Tables/Enhanced'
import GroupsTable from 'client/components/Tables/Groups'
@ -53,7 +52,6 @@ export {
ClustersTable,
DatastoresTable,
DockerHubTagsTable,
DataGridTable,
GroupsTable,
HostsTable,
ImagesTable,

View File

@ -1,121 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import React, { useMemo } from 'react'
import { Paper, Typography, Box } from '@mui/material'
export const CustomTooltip = React.memo(
({ active, payload, labels, generateColor, formatMetric, metricHues }) => {
if (active && payload && payload.length) {
const groupedMetrics = useMemo(
() =>
payload.reduce((acc, entry) => {
const [metric, datasetId] = entry.name.split('-')
if (!acc[datasetId]) {
acc[datasetId] = []
}
acc[datasetId].push({ metric, value: entry.value })
return acc
}, {}),
[payload]
)
const keys = Object.keys(groupedMetrics)
const itemWidth = keys.length === 1 ? '100%' : '50%'
return (
<Paper
elevation={3}
style={{
padding: '2px',
display: 'flex',
flexWrap: 'wrap',
maxWidth: '370px',
}}
>
{keys.map((datasetId, index) => (
<Box
key={`dataset-${datasetId}`}
style={{
padding: '0 1px',
width: itemWidth,
boxSizing: 'border-box',
maxWidth: '175px',
flex: 'none',
}}
>
<Typography
variant="body2"
style={{ fontWeight: 'bold', margin: '0 0 2px 0' }}
>
{labels[index]}
</Typography>
{/* eslint-disable-next-line no-shadow */}
{groupedMetrics[datasetId].map((entry, index) => {
const metricColor = generateColor(
entry.metric,
metricHues,
parseInt(datasetId, 10)
)
const formattedValue =
typeof entry.value === 'number'
? entry.value.toFixed(2)
: String(entry.value).slice(0, 12)
return (
<Typography
key={`metric-${index}`}
variant="body2"
style={{ margin: '0.5px 0' }}
>
<span style={{ color: metricColor }}>
{formatMetric(entry.metric)}:
</span>
{formattedValue}
</Typography>
)
})}
</Box>
))}
</Paper>
)
}
return null
}
)
CustomTooltip.displayName = 'CustomTooltip'
CustomTooltip.propTypes = {
active: PropTypes.bool,
payload: PropTypes.arrayOf(PropTypes.object),
labels: PropTypes.arrayOf(PropTypes.string),
generateColor: PropTypes.func,
formatMetric: PropTypes.func,
metricHues: PropTypes.objectOf(PropTypes.number).isRequired,
}
CustomTooltip.defaultProps = {
active: false,
payload: [],
labels: [],
generateColor: (metric, hues, id) => hues[metric] || '#000',
formatMetric: (input) => `${input}`,
}

View File

@ -1,18 +0,0 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { CustomTooltip } from './MultiChart/AccountingTooltip'
export { CustomTooltip }

View File

@ -52,10 +52,6 @@ const getDevConfiguration = () => {
path: path.resolve(__dirname, 'dist'),
publicPath: `${appName}/client`,
},
watchOptions: {
poll: 1000,
ignored: /node_modules/,
},
module: {
rules: [
{
@ -75,18 +71,6 @@ const getDevConfiguration = () => {
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpe?g|gif)$/i,
use: [
{
loader: 'file-loader',
options: {
name: '[path][name].[ext]',
outputPath: 'assets/images/',
},
},
],
},
],
},
resolve: {

View File

@ -36,19 +36,6 @@ const css = {
use: ['style-loader', 'css-loader'],
}
const images = {
test: /\.(png|jpe?g|gif)$/i,
use: [
{
loader: 'file-loader',
options: {
name: '[path][name].[ext]',
outputPath: 'assets/images/',
},
},
],
}
/**
* Bundle app.
*
@ -103,7 +90,7 @@ const bundle = ({ assets = false, name = 'sunstone' }) => {
minimizer: [new TerserPlugin({ extractComments: false })],
},
module: {
rules: [js, css, images],
rules: [js, css],
},
}
}

View File

@ -41,19 +41,6 @@ const css = {
],
}
const images = {
test: /\.(png|jpe?g|gif)$/i,
use: [
{
loader: 'file-loader',
options: {
name: '[path][name].[ext]',
outputPath: 'assets/images/',
},
},
],
}
const worker = {
test: /\.worker\.js$/,
loader: 'worker-loader',
@ -92,6 +79,6 @@ module.exports = {
minimizer: [new TerserPlugin({ extractComments: false })],
},
module: {
rules: [js, worker, css, images],
rules: [js, worker, css],
},
}