472 lines
19 KiB
JavaScript
472 lines
19 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
TextField, Box, Typography, IconButton, Divider,
|
||
CircularProgress, Alert, Collapse, Tooltip, Button,
|
||
Card, CardContent, Chip, Dialog, DialogTitle,
|
||
DialogContent, DialogActions, Snackbar, Table,
|
||
TableBody, TableCell, TableContainer, TableHead,
|
||
TableRow, Paper, Badge
|
||
} from '@mui/material';
|
||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||
import SearchIcon from '@mui/icons-material/Search';
|
||
import EditIcon from '@mui/icons-material/Edit';
|
||
import SaveIcon from '@mui/icons-material/Save';
|
||
import WarningIcon from '@mui/icons-material/Warning';
|
||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||
import axios from 'axios';
|
||
|
||
const FormulaItem = React.memo(({ formula, onEdit }) => {
|
||
const getMetricStatusColor = (found) => {
|
||
return found ? 'success' : 'error';
|
||
};
|
||
|
||
const formatValue = (value) => {
|
||
if (value === undefined) return 'N/A';
|
||
return value.toFixed(2);
|
||
};
|
||
|
||
return (
|
||
<Card sx={{ mb: 2, border: '1px solid', borderColor: 'divider' }}>
|
||
<CardContent>
|
||
{/* Заголовок с ID и статусом метрик */}
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||
<Box>
|
||
<Typography variant="h6" color="primary">
|
||
{formula.name}
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
ID: {formula.id}
|
||
</Typography>
|
||
</Box>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||
<Badge
|
||
badgeContent={formula.metadata?.missingMetrics}
|
||
color="error"
|
||
sx={{ mr: 1 }}
|
||
>
|
||
<Chip
|
||
label={`${formula.metadata?.foundMetrics || 0}/${formula.metadata?.totalMetrics || 0} метрик`}
|
||
color={formula.metadata?.missingMetrics === 0 ? "success" : "warning"}
|
||
size="small"
|
||
/>
|
||
</Badge>
|
||
<Button
|
||
startIcon={<EditIcon />}
|
||
onClick={() => onEdit(formula)}
|
||
variant="outlined"
|
||
size="small"
|
||
>
|
||
Редактировать
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Описание */}
|
||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||
{formula.description}
|
||
</Typography>
|
||
|
||
{/* Метрики */}
|
||
<Box sx={{ mt: 2 }}>
|
||
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||
Метрики в формуле:
|
||
{formula.metadata?.missingMetrics > 0 && (
|
||
<WarningIcon color="warning" fontSize="small" />
|
||
)}
|
||
</Typography>
|
||
|
||
<TableContainer component={Paper} variant="outlined" sx={{ mb: 2 }}>
|
||
<Table size="small">
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableCell>Метрика</TableCell>
|
||
<TableCell>Описание</TableCell>
|
||
<TableCell align="right">Значение</TableCell>
|
||
<TableCell>Статус</TableCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{formula.enrichedMetrics?.map((metric, index) => (
|
||
<TableRow key={index}>
|
||
<TableCell>
|
||
<Box>
|
||
<Typography variant="body2" fontWeight="bold">
|
||
{metric.originalName}
|
||
</Typography>
|
||
<Typography variant="caption" color="text.secondary">
|
||
{metric.prometheusName}
|
||
</Typography>
|
||
</Box>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Typography variant="body2">
|
||
{metric.description}
|
||
</Typography>
|
||
</TableCell>
|
||
<TableCell align="right">
|
||
<Typography
|
||
variant="body2"
|
||
fontWeight="bold"
|
||
color={metric.found ? 'text.primary' : 'text.disabled'}
|
||
>
|
||
{formatValue(metric.currentValue)}
|
||
</Typography>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Chip
|
||
icon={metric.found ? <CheckCircleIcon /> : <WarningIcon />}
|
||
label={metric.found ? 'Найдена' : 'Не найдена'}
|
||
color={getMetricStatusColor(metric.found)}
|
||
size="small"
|
||
variant={metric.found ? "filled" : "outlined"}
|
||
/>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Box>
|
||
|
||
{/* Формула */}
|
||
<Box sx={{ mt: 2 }}>
|
||
<Typography variant="subtitle2" gutterBottom>
|
||
Формула с описанием метрик:
|
||
</Typography>
|
||
|
||
<Box sx={{ mb: 2 }}>
|
||
<Typography variant="body2" sx={{
|
||
whiteSpace: 'pre-wrap',
|
||
wordBreak: 'break-word',
|
||
backgroundColor: 'primary.light',
|
||
color: 'primary.contrastText',
|
||
p: 2,
|
||
borderRadius: 1,
|
||
fontSize: '0.9rem',
|
||
fontFamily: 'monospace'
|
||
}}>
|
||
{formula.humanReadableFormula}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Веса */}
|
||
<Box sx={{ mt: 2 }}>
|
||
<Typography variant="subtitle2" gutterBottom>
|
||
Веса (warr):
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||
{formula.values?.warr?.map((weight, index) => (
|
||
<Chip
|
||
key={index}
|
||
label={`warr[${index + 1}]: ${weight}`}
|
||
size="small"
|
||
variant="outlined"
|
||
/>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
});
|
||
|
||
const EditFormulaDialog = ({ open, formula, onClose, onSave }) => {
|
||
const [editedFormula, setEditedFormula] = useState('');
|
||
|
||
useEffect(() => {
|
||
if (formula) {
|
||
setEditedFormula(formula.formula || '');
|
||
}
|
||
}, [formula]);
|
||
|
||
const handleSave = () => {
|
||
if (formula && editedFormula.trim()) {
|
||
onSave(formula.id, editedFormula.trim());
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||
<DialogTitle>
|
||
Редактирование формулы: {formula?.name}
|
||
</DialogTitle>
|
||
<DialogContent>
|
||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||
{formula?.description}
|
||
</Typography>
|
||
|
||
<Box sx={{ mt: 2, mb: 2 }}>
|
||
<Typography variant="subtitle2" gutterBottom>
|
||
Доступные переменные:
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||
<Chip label="statusarr[]" size="small" />
|
||
<Chip label="warr[]" size="small" />
|
||
</Box>
|
||
</Box>
|
||
|
||
<TextField
|
||
label="Формула"
|
||
value={editedFormula}
|
||
onChange={(e) => setEditedFormula(e.target.value)}
|
||
multiline
|
||
rows={6}
|
||
fullWidth
|
||
variant="outlined"
|
||
placeholder="Введите формулу..."
|
||
sx={{ mt: 2 }}
|
||
/>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={onClose}>Отмена</Button>
|
||
<Button
|
||
onClick={handleSave}
|
||
variant="contained"
|
||
startIcon={<SaveIcon />}
|
||
disabled={!editedFormula.trim()}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
const FormulaEditor = () => {
|
||
const [formulas, setFormulas] = useState([]);
|
||
const [filter, setFilter] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [editingFormula, setEditingFormula] = useState(null);
|
||
const [saveLoading, setSaveLoading] = useState(false);
|
||
const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' });
|
||
|
||
const showSnackbar = (message, severity = 'success') => {
|
||
setSnackbar({ open: true, message, severity });
|
||
};
|
||
|
||
const loadFormulas = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
const response = await axios.get('http://192.168.2.39:3000/api/enriched-formulas');
|
||
|
||
if (Array.isArray(response.data)) {
|
||
setFormulas(response.data);
|
||
showSnackbar(`Загружено ${response.data.length} формул`);
|
||
} else {
|
||
throw new Error('Некорректный формат данных');
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error('Ошибка при загрузке формул:', err);
|
||
const errorMessage = axios.isAxiosError(err)
|
||
? `Ошибка сервера: ${err.response?.status} - ${err.response?.data?.message || err.message}`
|
||
: `Ошибка загрузки: ${err.message}`;
|
||
setError(errorMessage);
|
||
showSnackbar(errorMessage, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
}, []);
|
||
|
||
const handleEditFormula = (formula) => {
|
||
setEditingFormula(formula);
|
||
};
|
||
|
||
const handleSaveFormula = async (formulaId, newFormula) => {
|
||
try {
|
||
setSaveLoading(true);
|
||
|
||
await axios.post(`http://192.168.2.39:3000/api/formula/${formulaId}/update`, {
|
||
formula: newFormula
|
||
});
|
||
|
||
setFormulas(prev => prev.map(formula =>
|
||
formula.id === formulaId
|
||
? { ...formula, formula: newFormula }
|
||
: formula
|
||
));
|
||
|
||
setEditingFormula(null);
|
||
showSnackbar('Формула успешно обновлена!');
|
||
|
||
} catch (err) {
|
||
console.error('Ошибка при сохранении формулы:', err);
|
||
showSnackbar('Ошибка при сохранении формулы', 'error');
|
||
} finally {
|
||
setSaveLoading(false);
|
||
}
|
||
};
|
||
|
||
const refreshData = useCallback(() => {
|
||
setRefreshing(true);
|
||
loadFormulas();
|
||
}, [loadFormulas]);
|
||
|
||
const filteredFormulas = formulas.filter(formula =>
|
||
formula.id.toLowerCase().includes(filter.toLowerCase()) ||
|
||
formula.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||
formula.description.toLowerCase().includes(filter.toLowerCase()) ||
|
||
formula.formula.toLowerCase().includes(filter.toLowerCase())
|
||
);
|
||
|
||
const totalMetrics = formulas.reduce((sum, formula) => sum + (formula.metadata?.totalMetrics || 0), 0);
|
||
const foundMetrics = formulas.reduce((sum, formula) => sum + (formula.metadata?.foundMetrics || 0), 0);
|
||
const missingMetrics = formulas.reduce((sum, formula) => sum + (formula.metadata?.missingMetrics || 0), 0);
|
||
|
||
useEffect(() => {
|
||
loadFormulas();
|
||
}, [loadFormulas]);
|
||
|
||
return (
|
||
<Box sx={{ position: 'relative', p: 2 }}>
|
||
{/* Загрузка */}
|
||
{(loading || refreshing) && (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||
<CircularProgress />
|
||
</Box>
|
||
)}
|
||
|
||
{/* Ошибки */}
|
||
<Collapse in={!!error}>
|
||
<Alert
|
||
severity="error"
|
||
sx={{ mb: 2 }}
|
||
action={
|
||
<Button color="inherit" size="small" onClick={refreshData}>
|
||
Повторить
|
||
</Button>
|
||
}
|
||
>
|
||
{error}
|
||
</Alert>
|
||
</Collapse>
|
||
|
||
{/* Панель управления */}
|
||
<Box sx={{ mb: 1 }}>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||
<Typography variant="h4" color="primary" fontWeight="bold">
|
||
Редактор формул с метриками
|
||
</Typography>
|
||
|
||
<Button
|
||
onClick={refreshData}
|
||
variant="contained"
|
||
startIcon={<RefreshIcon />}
|
||
disabled={refreshing}
|
||
>
|
||
Обновить
|
||
</Button>
|
||
</Box>
|
||
|
||
{/* Статистика */}
|
||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||
<Chip
|
||
label={`Формулы: ${formulas.length}`}
|
||
color="primary"
|
||
variant="outlined"
|
||
/>
|
||
<Chip
|
||
label={`Метрики: ${foundMetrics}/${totalMetrics}`}
|
||
color={missingMetrics === 0 ? "success" : "warning"}
|
||
variant="outlined"
|
||
/>
|
||
{missingMetrics > 0 && (
|
||
<Chip
|
||
label={`Отсутствуют: ${missingMetrics}`}
|
||
color="error"
|
||
variant="outlined"
|
||
/>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Поиск */}
|
||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1, mb: 2 }}>
|
||
<TextField
|
||
label="Поиск по формулам"
|
||
fullWidth
|
||
value={filter}
|
||
onChange={(e) => setFilter(e.target.value)}
|
||
variant="outlined"
|
||
placeholder="Введите ID, название или описание..."
|
||
size="small"
|
||
/>
|
||
<SearchIcon sx={{ color: 'action.active', mb: 0.5 }} />
|
||
</Box>
|
||
</Box>
|
||
|
||
<Divider sx={{ mb: 3 }} />
|
||
|
||
{/* Список формул */}
|
||
<Box sx={{ maxHeight: '70vh', overflowY: 'auto', pr: 1 }}>
|
||
{filteredFormulas.map((formula) => (
|
||
<FormulaItem
|
||
key={formula.id}
|
||
formula={formula}
|
||
onEdit={handleEditFormula}
|
||
/>
|
||
))}
|
||
|
||
{filteredFormulas.length === 0 && !loading && (
|
||
<Typography
|
||
color="text.secondary"
|
||
textAlign="center"
|
||
py={3}
|
||
variant="h6"
|
||
>
|
||
{filter ? 'Формулы не найдены' : 'Нет загруженных формул'}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Статус бар */}
|
||
<Box sx={{
|
||
position: 'sticky',
|
||
bottom: 0,
|
||
backgroundColor: 'background.paper',
|
||
p: 1,
|
||
borderTop: 1,
|
||
borderColor: 'divider',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center'
|
||
}}>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Всего формул: {formulas.length} • Отфильтровано: {filteredFormulas.length}
|
||
</Typography>
|
||
<Typography variant="body2" color={missingMetrics === 0 ? "success.main" : "warning.main"}>
|
||
Метрики: {foundMetrics}/{totalMetrics} найдено
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* Диалог редактирования */}
|
||
<EditFormulaDialog
|
||
open={!!editingFormula}
|
||
formula={editingFormula}
|
||
onClose={() => setEditingFormula(null)}
|
||
onSave={handleSaveFormula}
|
||
/>
|
||
|
||
{/* Уведомления */}
|
||
<Snackbar
|
||
open={snackbar.open}
|
||
autoHideDuration={6000}
|
||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||
>
|
||
<Alert
|
||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||
severity={snackbar.severity}
|
||
>
|
||
{snackbar.message}
|
||
</Alert>
|
||
</Snackbar>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default React.memo(FormulaEditor); |