feat: Enhance error handling in server responses by including request IDs and detailed error codes; improve frontend modal components for better accessibility and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m59s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m59s
This commit is contained in:
+51
-48
@@ -125,7 +125,8 @@ function sendOk(res, meta) {
|
||||
}
|
||||
|
||||
function sendError(res, status, message, code, details) {
|
||||
return res.status(status).json({ message, code, details });
|
||||
const requestId = res.req?.id;
|
||||
return res.status(status).json({ code, message, details, requestId });
|
||||
}
|
||||
|
||||
// Map UI resource -> S3 key (for history endpoints)
|
||||
@@ -460,7 +461,7 @@ app.post('/api/domains', async (req, res) => {
|
||||
res.send('File updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error writing to S3');
|
||||
return sendError(res, 500, 'Error writing to S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -516,7 +517,7 @@ app.get('/api/asns', async (req, res) => {
|
||||
res.json([]);
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading from S3');
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -607,7 +608,7 @@ app.get('/api/domains-new', async (req, res) => {
|
||||
res.json([]); // Return empty array if file does not exist
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading from S3');
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -698,7 +699,7 @@ app.get('/api/ip-ranges', async (req, res) => {
|
||||
res.json([]); // Return empty array if file does not exist
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading from S3');
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -779,7 +780,7 @@ app.get('/api/communities', async (req, res) => {
|
||||
return res.json([]);
|
||||
}
|
||||
console.error('Error reading communities from S3:', error);
|
||||
res.status(500).send('Error reading communities from S3');
|
||||
return sendError(res, 500, 'Error reading communities from S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -788,7 +789,7 @@ app.post('/api/communities', async (req, res) => {
|
||||
const { communities } = req.body;
|
||||
|
||||
if (!Array.isArray(communities)) {
|
||||
return res.status(400).send('communities must be an array');
|
||||
return sendError(res, 400, 'communities must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Validate entries and ensure unique values
|
||||
@@ -798,10 +799,10 @@ app.post('/api/communities', async (req, res) => {
|
||||
const entry = communities[i] || {};
|
||||
const value = typeof entry.value === 'string' ? entry.value.trim() : '';
|
||||
if (!value) {
|
||||
return res.status(400).send(`Community at index ${i} is missing required field: value`);
|
||||
return sendError(res, 400, `Community at index ${i} is missing required field: value`, 'E_SCHEMA');
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return res.status(400).send(`Duplicate community value at index ${i}: ${value}`);
|
||||
return sendError(res, 400, `Duplicate community value at index ${i}: ${value}`, 'E_SCHEMA');
|
||||
}
|
||||
seen.add(value);
|
||||
normalized.push({
|
||||
@@ -826,7 +827,7 @@ app.post('/api/communities', async (req, res) => {
|
||||
res.send('Communities updated successfully');
|
||||
} catch (error) {
|
||||
console.error('Error writing communities to S3:', error);
|
||||
res.status(500).send('Error writing communities to S3');
|
||||
return sendError(res, 500, 'Error writing communities to S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -861,7 +862,7 @@ app.get('/api/servers', async (req, res) => {
|
||||
res.json([]); // Return empty array if file does not exist
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading from S3');
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -872,14 +873,14 @@ app.post('/api/servers', async (req, res) => {
|
||||
|
||||
// Validate servers structure
|
||||
if (!Array.isArray(servers)) {
|
||||
return res.status(400).send('Servers must be an array');
|
||||
return sendError(res, 400, 'Servers must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Validate each server has required fields
|
||||
for (let i = 0; i < servers.length; i++) {
|
||||
const server = servers[i];
|
||||
if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) {
|
||||
return res.status(400).send(`Server at index ${i} is missing required fields`);
|
||||
return sendError(res, 400, `Server at index ${i} is missing required fields`, 'E_SCHEMA');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -895,7 +896,7 @@ app.post('/api/servers', async (req, res) => {
|
||||
res.send('File updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error writing to S3');
|
||||
return sendError(res, 500, 'Error writing to S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -930,7 +931,7 @@ app.get('/api/billing', async (req, res) => {
|
||||
res.json([]); // Return empty array if file does not exist
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading from S3');
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -941,14 +942,14 @@ app.post('/api/billing', async (req, res) => {
|
||||
|
||||
// Validate billing data structure
|
||||
if (!Array.isArray(billingData)) {
|
||||
return res.status(400).send('Billing data must be an array');
|
||||
return sendError(res, 400, 'Billing data must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Validate each billing item has required fields
|
||||
for (let i = 0; i < billingData.length; i++) {
|
||||
const item = billingData[i];
|
||||
if (!item.hostName || !item.country || !item.provider) {
|
||||
return res.status(400).send(`Billing item at index ${i} is missing required fields: hostName, country, provider`);
|
||||
return sendError(res, 400, `Billing item at index ${i} is missing required fields: hostName, country, provider`, 'E_SCHEMA');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -964,7 +965,7 @@ app.post('/api/billing', async (req, res) => {
|
||||
res.send('File updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error writing to S3');
|
||||
return sendError(res, 500, 'Error writing to S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -999,7 +1000,7 @@ app.get('/api/filters', async (req, res) => {
|
||||
res.json([]); // Return empty array if file does not exist
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading from S3');
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1010,14 +1011,14 @@ app.post('/api/filters', async (req, res) => {
|
||||
|
||||
// Validate filters structure
|
||||
if (!Array.isArray(filters)) {
|
||||
return res.status(400).send('Filters must be an array');
|
||||
return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Validate each filter has required fields
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
const filter = filters[i];
|
||||
if (!filter.community || !filter.gateway) {
|
||||
return res.status(400).send(`Filter at index ${i} is missing required fields`);
|
||||
return sendError(res, 400, `Filter at index ${i} is missing required fields`, 'E_SCHEMA');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,7 +1034,7 @@ app.post('/api/filters', async (req, res) => {
|
||||
res.send('File updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error writing to S3');
|
||||
return sendError(res, 500, 'Error writing to S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1066,7 +1067,7 @@ app.get('/api/s3/last-modified', async (req, res) => {
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
console.error('Error fetching last modified dates from S3:', error);
|
||||
res.status(500).send('Error fetching last modified dates from S3');
|
||||
return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1190,7 +1191,7 @@ app.get('/api/filters/generate-config', async (req, res) => {
|
||||
res.json({ config: '// filters.json file not found' });
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error generating configuration');
|
||||
return sendError(res, 500, 'Error generating configuration', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1252,7 +1253,7 @@ app.post('/api/filters/export-config', async (req, res) => {
|
||||
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error exporting configuration');
|
||||
return sendError(res, 500, 'Error exporting configuration', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1286,7 +1287,7 @@ app.get('/api/server-configs', async (req, res) => {
|
||||
res.json([]); // Return empty array if file does not exist
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading server configs from S3');
|
||||
return sendError(res, 500, 'Error reading server configs from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1297,14 +1298,14 @@ app.post('/api/server-configs', async (req, res) => {
|
||||
|
||||
// Validate servers structure
|
||||
if (!Array.isArray(servers)) {
|
||||
return res.status(400).send('Servers must be an array');
|
||||
return sendError(res, 400, 'Servers must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Validate each server has required fields
|
||||
for (let i = 0; i < servers.length; i++) {
|
||||
const server = servers[i];
|
||||
if (!server.id || !server.name) {
|
||||
return res.status(400).send(`Server at index ${i} is missing required fields`);
|
||||
return sendError(res, 400, `Server at index ${i} is missing required fields`, 'E_SCHEMA');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1320,7 +1321,7 @@ app.post('/api/server-configs', async (req, res) => {
|
||||
res.send('Server configs updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error writing server configs to S3');
|
||||
return sendError(res, 500, 'Error writing server configs to S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1341,7 +1342,7 @@ app.get('/api/server-configs/:serverId', async (req, res) => {
|
||||
res.json({ config: '// Конфигурация не найдена' });
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading server config from S3');
|
||||
return sendError(res, 500, 'Error reading server config from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1352,7 +1353,7 @@ app.post('/api/server-configs/:serverId', async (req, res) => {
|
||||
const { config } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).send('Config is required');
|
||||
return sendError(res, 400, 'Config is required', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
const params = {
|
||||
@@ -1367,7 +1368,7 @@ app.post('/api/server-configs/:serverId', async (req, res) => {
|
||||
res.send('Server config saved successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error writing server config to S3');
|
||||
return sendError(res, 500, 'Error writing server config to S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1384,7 +1385,7 @@ app.delete('/api/server-configs/:serverId', async (req, res) => {
|
||||
res.send('Server config deleted successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error deleting server config from S3');
|
||||
return sendError(res, 500, 'Error deleting server config from S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1414,7 +1415,7 @@ app.delete('/api/server-configs/:serverId/complete', async (req, res) => {
|
||||
res.send('Server and all associated files deleted successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error deleting server files from S3');
|
||||
return sendError(res, 500, 'Error deleting server files from S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1449,7 +1450,7 @@ app.get('/api/server-filters/:serverId', async (req, res) => {
|
||||
res.json([]); // Return empty array if file does not exist
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading server filters from S3');
|
||||
return sendError(res, 500, 'Error reading server filters from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1497,14 +1498,14 @@ app.post('/api/server-filters/:serverId', async (req, res) => {
|
||||
|
||||
// Validate filters structure
|
||||
if (!Array.isArray(filters)) {
|
||||
return res.status(400).send('Filters must be an array');
|
||||
return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Validate each filter has required fields
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
const filter = filters[i];
|
||||
if (!filter.community || !filter.gateway) {
|
||||
return res.status(400).send(`Filter at index ${i} is missing required fields`);
|
||||
return sendError(res, 400, `Filter at index ${i} is missing required fields`, 'E_SCHEMA');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1520,7 +1521,7 @@ app.post('/api/server-filters/:serverId', async (req, res) => {
|
||||
res.send('Server filters updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error writing server filters to S3');
|
||||
return sendError(res, 500, 'Error writing server filters to S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1554,7 +1555,7 @@ app.get('/api/simple-filters', async (req, res) => {
|
||||
res.json([]); // Return empty array if file does not exist
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading simple filters from S3');
|
||||
return sendError(res, 500, 'Error reading simple filters from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1565,14 +1566,14 @@ app.post('/api/simple-filters', async (req, res) => {
|
||||
|
||||
// Validate filters structure
|
||||
if (!Array.isArray(filters)) {
|
||||
return res.status(400).send('Filters must be an array');
|
||||
return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Validate each filter has required fields
|
||||
for (let i = 0; i < filters.length; i++) {
|
||||
const filter = filters[i];
|
||||
if (!filter.community || !filter.gateway) {
|
||||
return res.status(400).send(`Filter at index ${i} is missing required fields`);
|
||||
return sendError(res, 400, `Filter at index ${i} is missing required fields`, 'E_SCHEMA');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1588,7 +1589,7 @@ app.post('/api/simple-filters', async (req, res) => {
|
||||
res.send('Simple filters updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error writing simple filters to S3');
|
||||
return sendError(res, 500, 'Error writing simple filters to S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1616,7 +1617,7 @@ app.get('/api/auto-urls', async (req, res) => {
|
||||
res.json([]); // Return empty array if file does not exist
|
||||
} else {
|
||||
console.error(error);
|
||||
res.status(500).send('Error reading auto URLs from S3');
|
||||
return sendError(res, 500, 'Error reading auto URLs from S3', 'E_S3');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1714,7 +1715,7 @@ app.post('/api/auto-urls', async (req, res) => {
|
||||
res.send('Auto URLs updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Error writing auto URLs to S3');
|
||||
return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1745,7 +1746,7 @@ app.post('/api/auto-urls/process', async (req, res) => {
|
||||
}
|
||||
|
||||
if (urls.length === 0) {
|
||||
return res.status(400).send('No URLs to process');
|
||||
return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Get current IPs
|
||||
@@ -1830,7 +1831,7 @@ app.post('/api/auto-urls/process', async (req, res) => {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing auto URLs:', error);
|
||||
res.status(500).send('Error processing auto URLs');
|
||||
return sendError(res, 500, 'Error processing auto URLs', 'E_S3');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1846,8 +1847,10 @@ app.use((err, req, res, next) => {
|
||||
const status = typeof err?.status === 'number' ? err.status : 500;
|
||||
const code = err?.code || 'E_INTERNAL';
|
||||
const message = status === 500 && process.env.NODE_ENV === 'production' ? 'Internal Server Error' : (err?.message || 'Error');
|
||||
try { console.error('error:', err); } catch {}
|
||||
res.status(status).json({ message, code });
|
||||
const details = err?.details;
|
||||
const requestId = req?.id;
|
||||
try { req.log?.error({ err, code, requestId }, 'request error'); } catch {}
|
||||
res.status(status).json({ code, message, details, requestId });
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
|
||||
@@ -9,9 +9,18 @@ export default function ConfirmDialog({ open, title, message, confirmText = 'П
|
||||
}, [open])
|
||||
if (!open) return null
|
||||
return (
|
||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.() }}>
|
||||
<div className="modal-dialog" role="document">
|
||||
<div className="modal-content" ref={ref}>
|
||||
<div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => {
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
||||
if (!focusable || focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}}>
|
||||
<div className="modal-header">
|
||||
<h5 id="confirm-title" className="modal-title">{title || 'Подтверждение'}</h5>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={onCancel}></button>
|
||||
|
||||
@@ -4,11 +4,21 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose }) {
|
||||
const removed = diff?.removed?.length || 0;
|
||||
const changed = diff?.changed?.length || 0;
|
||||
return (
|
||||
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-diff-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
|
||||
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<div className="modal-content" tabIndex={-1} onKeyDown={(e) => {
|
||||
if (e.key === 'Tab') {
|
||||
const c = e.currentTarget
|
||||
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
||||
if (!focusable || focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}}>
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Подтвердить сохранение</h5>
|
||||
<h5 id="confirm-diff-title" className="modal-title">Подтвердить сохранение</h5>
|
||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../lib/api.js';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import ConfirmDialog from './ConfirmDialog.jsx';
|
||||
import { useNotify } from './NotifyProvider.jsx';
|
||||
import { IconHistory, IconRefresh, IconDeviceFloppy } from '@tabler/icons-react';
|
||||
|
||||
@@ -22,30 +23,49 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
|
||||
|
||||
useEffect(() => { if (show) refetch().catch(() => notify.error('Не удалось загрузить историю версий')); }, [show, resource]);
|
||||
|
||||
const [confirmState, setConfirmState] = useState({ open: false, onConfirm: null });
|
||||
const rollback = async (versionId) => {
|
||||
if (!versionId) return;
|
||||
if (!confirm('Откатить к выбранной версии? Текущее содержимое будет перезаписано.')) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post(`/history/${resource}/rollback`, { versionId });
|
||||
notify.success('Откат выполнен');
|
||||
onRolledBack && onRolledBack(res.data || {});
|
||||
onClose && onClose();
|
||||
} catch (e) {
|
||||
notify.error('Не удалось выполнить откат');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
onConfirm: async () => {
|
||||
setConfirmState({ open: false, onConfirm: null });
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post(`/history/${resource}/rollback`, { versionId });
|
||||
notify.success('Откат выполнен');
|
||||
onRolledBack && onRolledBack(res.data || {});
|
||||
onClose && onClose();
|
||||
} catch (e) {
|
||||
notify.error('Не удалось выполнить откат');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="history-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<div className="modal-content" tabIndex={-1} onKeyDown={(e) => {
|
||||
if (e.key === 'Tab') {
|
||||
const c = e.currentTarget
|
||||
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
||||
if (!focusable || focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}}>
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title d-flex align-items-center">
|
||||
<h5 id="history-title" className="modal-title d-flex align-items-center">
|
||||
<IconHistory className="me-2" /> История версий
|
||||
</h5>
|
||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||
@@ -96,6 +116,14 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirmState.open}
|
||||
title="Подтверждение"
|
||||
message="Откатить к выбранной версии? Текущее содержимое будет перезаписано."
|
||||
confirmText="Откатить"
|
||||
onCancel={() => setConfirmState({ open: false, onConfirm: null })}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,18 +37,18 @@ export function NotifyProvider({ children }) {
|
||||
timeouts.current.set(id, t);
|
||||
}, [remove]);
|
||||
|
||||
const add = useCallback((type, message) => {
|
||||
const add = useCallback((type, message, details) => {
|
||||
const text = String(message || '').trim();
|
||||
if (!text) return;
|
||||
setItems((prev) => {
|
||||
const dup = prev.find((n) => n.type === type && n.message === text);
|
||||
if (dup) {
|
||||
const updated = prev.map((n) => n.id === dup.id ? { ...n, count: (n.count || 1) + 1, createdAt: Date.now() } : n);
|
||||
const updated = prev.map((n) => n.id === dup.id ? { ...n, count: (n.count || 1) + 1, createdAt: Date.now(), details: details ?? n.details } : n);
|
||||
schedule(dup.id, type);
|
||||
return updated;
|
||||
}
|
||||
const id = idSeq.current++;
|
||||
const next = [...prev, { id, type, message: text, count: 1, createdAt: Date.now() }];
|
||||
const next = [...prev, { id, type, message: text, details, count: 1, createdAt: Date.now() }];
|
||||
schedule(id, type);
|
||||
return next;
|
||||
});
|
||||
@@ -62,10 +62,10 @@ export function NotifyProvider({ children }) {
|
||||
|
||||
const api = useMemo(() => ({
|
||||
add,
|
||||
success: (m) => add('success', m),
|
||||
error: (m) => add('error', m),
|
||||
info: (m) => add('info', m),
|
||||
warning: (m) => add('warning', m),
|
||||
success: (m, d) => add('success', m, d),
|
||||
error: (m, d) => add('error', m, d),
|
||||
info: (m, d) => add('info', m, d),
|
||||
warning: (m, d) => add('warning', m, d),
|
||||
remove,
|
||||
clear,
|
||||
}), [add, remove, clear]);
|
||||
@@ -116,6 +116,12 @@ function NotifyViewport({ items, onClose }) {
|
||||
<div className="me-1 mt-1">{iconByType(n.type)}</div>
|
||||
<div className="flex-grow-1">
|
||||
{n.message}
|
||||
{n.details && (
|
||||
<details className="small mt-1">
|
||||
<summary>Подробнее</summary>
|
||||
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>{typeof n.details === 'string' ? n.details : JSON.stringify(n.details, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
{n.count > 1 && (
|
||||
<span className="badge bg-white text-body border ms-2">×{n.count}</span>
|
||||
)}
|
||||
|
||||
@@ -34,9 +34,13 @@ api.interceptors.response.use(
|
||||
(err) => {
|
||||
try {
|
||||
const status = err?.response?.status;
|
||||
const message = err?.response?.data?.message || err?.message || 'Ошибка запроса';
|
||||
const data = err?.response?.data || {};
|
||||
const message = data?.message || err?.message || 'Ошибка запроса';
|
||||
const code = data?.code;
|
||||
const details = data?.details;
|
||||
const requestId = data?.requestId || err?.response?.headers?.['x-request-id'] || err?.config?.headers?.['X-Request-Id'];
|
||||
if (status >= 400 && typeof window !== 'undefined' && window.notify?.error) {
|
||||
window.notify.error(`${message} (${status ?? '—'})`);
|
||||
window.notify.add('error', `${message}${status ? ` (${status})` : ''}${requestId ? ` • reqId=${requestId}` : ''}`, details ? { code, requestId, details } : undefined);
|
||||
}
|
||||
} catch {}
|
||||
return Promise.reject(err);
|
||||
|
||||
Reference in New Issue
Block a user