diff --git a/backend/server.js b/backend/server.js index 0405c0f..a1d8deb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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, () => { diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx index e4ad993..01ac662 100644 --- a/frontend/src/components/ConfirmDialog.jsx +++ b/frontend/src/components/ConfirmDialog.jsx @@ -9,9 +9,18 @@ export default function ConfirmDialog({ open, title, message, confirmText = 'П }, [open]) if (!open) return null return ( -
+
{ if (e.key === 'Escape') onCancel?.() }}>
-
+
{ + 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(); } + } + }}>
{title || 'Подтверждение'}
diff --git a/frontend/src/components/ConfirmDiffModal.jsx b/frontend/src/components/ConfirmDiffModal.jsx index 15ea0b0..020a82c 100644 --- a/frontend/src/components/ConfirmDiffModal.jsx +++ b/frontend/src/components/ConfirmDiffModal.jsx @@ -4,11 +4,21 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose }) { const removed = diff?.removed?.length || 0; const changed = diff?.changed?.length || 0; return ( -
+
{ if (e.key === 'Escape') onClose?.() }}>
-
+
{ + 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(); } + } + }}>
-
Подтвердить сохранение
+
Подтвердить сохранение
diff --git a/frontend/src/components/HistoryModal.jsx b/frontend/src/components/HistoryModal.jsx index 861cf13..1a14ea6 100644 --- a/frontend/src/components/HistoryModal.jsx +++ b/frontend/src/components/HistoryModal.jsx @@ -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 ( -
+
{ if (e.key === 'Escape') onClose?.() }}>
-
+
{ + 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(); } + } + }}>
-
+
История версий
@@ -96,6 +116,14 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
+ setConfirmState({ open: false, onConfirm: null })} + onConfirm={confirmState.onConfirm} + />
); } diff --git a/frontend/src/components/NotifyProvider.jsx b/frontend/src/components/NotifyProvider.jsx index 7ab34ce..108e564 100644 --- a/frontend/src/components/NotifyProvider.jsx +++ b/frontend/src/components/NotifyProvider.jsx @@ -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 }) {
{iconByType(n.type)}
{n.message} + {n.details && ( +
+ Подробнее +
{typeof n.details === 'string' ? n.details : JSON.stringify(n.details, null, 2)}
+
+ )} {n.count > 1 && ( ×{n.count} )} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 541c7da..51e2fc1 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -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);