feat: Заменить рендеринг бейджей сообщества на компонент CommunityBadge в менеджерах ASNs, Domains и IPRanges, улучшив читаемость кода и унифицировав отображение. Добавить возможность скачивания шаблонов CSV для пустых состояний в этих менеджерах, улучшая пользовательский опыт. Обновить модальные окна подтверждения удаления для более удобного взаимодействия с пользователем.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m52s

This commit is contained in:
2025-08-27 19:48:58 +07:00
parent ac0cbc8c31
commit 730017405c
8 changed files with 171 additions and 174 deletions
+25 -56
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import api from './lib/api.js'; import api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
import { import {
IconPlus, IconPlus,
IconSearch, IconSearch,
@@ -78,17 +79,7 @@ function ASNsNewManager() {
// Подгружаем имена ASN из кэша/внешних API (эффект будет размещен ниже после определения paginatedItems) // Подгружаем имена ASN из кэша/внешних API (эффект будет размещен ниже после определения paginatedItems)
const renderCommunityBadge = (value) => { const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
const v = String(value ?? '').trim();
if (!v) return <span className="badge bg-blue-lt text-blue">—</span>;
const meta = communities.find(c => c.value === v);
const color = (meta?.color || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
const cls = `badge bg-${color}-lt text-${color}`;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
return (
<span className={cls} title={title}>{v}</span>
);
};
const isValidAsn = (value) => /^[0-9]+$/.test(String(value).trim()); const isValidAsn = (value) => /^[0-9]+$/.test(String(value).trim());
// Допускаем как числовые, так и строковые (AS:NNN) community // Допускаем как числовые, так и строковые (AS:NNN) community
@@ -635,7 +626,18 @@ function ASNsNewManager() {
<TableSkeleton rows={10} cols={3} /> <TableSkeleton rows={10} cols={3} />
) : paginatedItems.length === 0 ? ( ) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}> <TableEmpty cols={3}>
<EmptyState title="Нет ASN" description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} /> <EmptyState
title="Нет ASN"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['asn','community'];
const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
/>
</TableEmpty> </TableEmpty>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
@@ -748,50 +750,17 @@ function ASNsNewManager() {
</div> </div>
</div> </div>
{/* Delete Confirmation Modal */} <ConfirmDialog
{showDeleteModal && ( open={showDeleteModal}
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1"> title={'Удалить ASN?'}
<div className="modal-dialog modal-sm modal-dialog-centered" role="document"> message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.asn}"? Это действие необратимо.`}
<div className="modal-content"> confirmText={'Удалить'}
<button cancelText={'Отмена'}
type="button" destructive
className="btn-close" size={'sm'}
onClick={() => setShowDeleteModal(false)} onConfirm={executeDelete}
></button> onCancel={() => setShowDeleteModal(false)}
<div className="modal-status bg-danger"></div> />
<div className="modal-body text-center py-4">
<IconTrash className="icon mb-2 text-danger icon-lg" />
<h3>Удалить ASN?</h3>
<div className="text-muted">
Вы уверены, что хотите удалить "{itemToDelete?.asn}"? Это действие необратимо.
</div>
</div>
<div className="modal-footer">
<div className="w-100">
<div className="row">
<div className="col">
<button
className="btn w-100"
onClick={() => setShowDeleteModal(false)}
>
Отмена
</button>
</div>
<div className="col">
<button
className="btn btn-danger w-100"
onClick={executeDelete}
>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<ConfirmDiffModal <ConfirmDiffModal
show={confirmSaveOpen} show={confirmSaveOpen}
diff={diff} diff={diff}
+25 -56
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import api from './lib/api.js'; import api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
import { import {
IconPlus, IconPlus,
IconSearch, IconSearch,
@@ -74,17 +75,7 @@ function DomainsNewManager() {
})(); })();
}, []); }, []);
const renderCommunityBadge = (value) => { const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
const v = String(value ?? '').trim();
if (!v) return <span className="badge bg-blue-lt text-blue">—</span>;
const meta = communities.find(c => c.value === v);
const color = (meta?.color || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
const cls = `badge bg-${color}-lt text-${color}`;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
return (
<span className={cls} title={title}>{v}</span>
);
};
const isValidDomain = (value) => { const isValidDomain = (value) => {
const v = String(value).trim().toLowerCase(); const v = String(value).trim().toLowerCase();
@@ -623,7 +614,18 @@ function DomainsNewManager() {
<TableSkeleton rows={10} cols={3} /> <TableSkeleton rows={10} cols={3} />
) : paginatedItems.length === 0 ? ( ) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}> <TableEmpty cols={3}>
<EmptyState title="Нет доменов" description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} /> <EmptyState
title="Нет доменов"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['domain','community'];
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
/>
</TableEmpty> </TableEmpty>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
@@ -726,50 +728,17 @@ function DomainsNewManager() {
</div> </div>
</div> </div>
{/* Delete Confirmation Modal */} <ConfirmDialog
{showDeleteModal && ( open={showDeleteModal}
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1"> title={'Удалить домен?'}
<div className="modal-dialog modal-sm modal-dialog-centered" role="document"> message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.domain}"? Это действие необратимо.`}
<div className="modal-content"> confirmText={'Удалить'}
<button cancelText={'Отмена'}
type="button" destructive
className="btn-close" size={'sm'}
onClick={() => setShowDeleteModal(false)} onConfirm={executeDelete}
></button> onCancel={() => setShowDeleteModal(false)}
<div className="modal-status bg-danger"></div> />
<div className="modal-body text-center py-4">
<IconTrash className="icon mb-2 text-danger icon-lg" />
<h3>Удалить домен?</h3>
<div className="text-muted">
Вы уверены, что хотите удалить "{itemToDelete?.domain}"? Это действие необратимо.
</div>
</div>
<div className="modal-footer">
<div className="w-100">
<div className="row">
<div className="col">
<button
className="btn w-100"
onClick={() => setShowDeleteModal(false)}
>
Отмена
</button>
</div>
<div className="col">
<button
className="btn btn-danger w-100"
onClick={executeDelete}
>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Confirm Save Modal */} {/* Confirm Save Modal */}
<ConfirmDiffModal <ConfirmDiffModal
show={confirmSaveOpen} show={confirmSaveOpen}
+34 -56
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import api from './lib/api.js'; import api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
import { import {
IconPlus, IconPlus,
IconSearch, IconSearch,
@@ -74,17 +75,7 @@ function IPRangesManager() {
})(); })();
}, []); }, []);
const renderCommunityBadge = (value) => { const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
const v = String(value ?? '').trim();
if (!v) return <span className="badge bg-blue-lt text-blue">—</span>;
const meta = communities.find(c => c.value === v);
const color = (meta?.color || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
const cls = `badge bg-${color}-lt text-${color}`;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
return (
<span className={cls} title={title}>{v}</span>
);
};
const isValidIPv4 = (ip) => { const isValidIPv4 = (ip) => {
const octets = ip.split('.'); const octets = ip.split('.');
@@ -364,6 +355,21 @@ function IPRangesManager() {
setImportOpen(false); setImportOpen(false);
}; };
// Шаблон CSV для пустого состояния
const downloadSampleCsv = () => {
const header = ['ipRange','community'];
const lines = [header, ['192.168.1.0/24', '65000:100']]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ip_ranges_sample.csv';
a.click();
URL.revokeObjectURL(url);
};
// Сортировка // Сортировка
const sortedItems = [...items].sort((a, b) => { const sortedItems = [...items].sort((a, b) => {
let valA = a[sortField] || ''; let valA = a[sortField] || '';
@@ -878,7 +884,12 @@ function IPRangesManager() {
<TableSkeleton rows={10} cols={3} /> <TableSkeleton rows={10} cols={3} />
) : paginatedItems.length === 0 ? ( ) : paginatedItems.length === 0 ? (
<TableEmpty cols={3}> <TableEmpty cols={3}>
<EmptyState title="Нет IP-диапазонов" description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} /> <EmptyState
title="Нет IP-диапазонов"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон CSV</button>}
/>
</TableEmpty> </TableEmpty>
) : ( ) : (
<div className="table-responsive"> <div className="table-responsive">
@@ -986,50 +997,17 @@ function IPRangesManager() {
</div> </div>
</div> </div>
{/* Delete Confirmation Modal */} <ConfirmDialog
{showDeleteModal && ( open={showDeleteModal}
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1"> title={'Удалить IP-диапазон?'}
<div className="modal-dialog modal-sm modal-dialog-centered" role="document"> message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.ipRange}"? Это действие необратимо.`}
<div className="modal-content"> confirmText={'Удалить'}
<button cancelText={'Отмена'}
type="button" destructive
className="btn-close" size={'sm'}
onClick={() => setShowDeleteModal(false)} onConfirm={executeDelete}
></button> onCancel={() => setShowDeleteModal(false)}
<div className="modal-status bg-danger"></div> />
<div className="modal-body text-center py-4">
<IconTrash className="icon mb-2 text-danger icon-lg" />
<h3>Удалить IP-диапазон?</h3>
<div className="text-muted">
Вы уверены, что хотите удалить "{itemToDelete?.ipRange}"? Это действие необратимо.
</div>
</div>
<div className="modal-footer">
<div className="w-100">
<div className="row">
<div className="col">
<button
className="btn w-100"
onClick={() => setShowDeleteModal(false)}
>
Отмена
</button>
</div>
<div className="col">
<button
className="btn btn-danger w-100"
onClick={executeDelete}
>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<ConfirmDiffModal <ConfirmDiffModal
show={confirmSaveOpen} show={confirmSaveOpen}
diff={diff} diff={diff}
@@ -0,0 +1,21 @@
function normalizeTablerColor(input) {
const allowed = new Set(['blue','azure','indigo','purple','pink','red','orange','yellow','lime','green','teal','cyan','grape','gray']);
const c = String(input || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
return allowed.has(c) ? c : 'blue';
}
function CommunityBadge({ value, communities = [] }) {
const v = String(value ?? '').trim();
if (!v) return <span className="badge bg-blue-lt text-blue">—</span>;
const meta = communities.find(c => String(c?.value) === v);
const color = normalizeTablerColor(meta?.color || 'blue');
const cls = `badge bg-${color}-lt text-${color}`;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${Array.isArray(meta.tags) && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
return (
<span className={cls} title={title}>{v}</span>
);
}
export default CommunityBadge;
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
export default function ConfirmDialog({ open, title, message, confirmText = 'Подтвердить', cancelText = 'Отмена', onConfirm, onCancel }) { export default function ConfirmDialog({ open, title, message, confirmText = 'Подтвердить', cancelText = 'Отмена', onConfirm, onCancel, destructive = false, size = 'md' }) {
const ref = useRef(null) const ref = useRef(null)
useEffect(() => { useEffect(() => {
if (open && ref.current) { if (open && ref.current) {
@@ -10,7 +10,7 @@ export default function ConfirmDialog({ open, title, message, confirmText = 'П
if (!open) return null if (!open) return null
return ( 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)' }} onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.() }}> <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-dialog ${size === 'sm' ? 'modal-sm' : size === 'lg' ? 'modal-lg' : ''}`} role="document">
<div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => { <div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => {
if (e.key === 'Tab') { if (e.key === 'Tab') {
const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
@@ -30,7 +30,7 @@ export default function ConfirmDialog({ open, title, message, confirmText = 'П
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onCancel}>{cancelText}</button> <button type="button" className="btn btn-secondary" onClick={onCancel}>{cancelText}</button>
<button type="button" className="btn btn-primary" data-primary onClick={onConfirm}>{confirmText}</button> <button type="button" className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`} data-primary onClick={onConfirm}>{confirmText}</button>
</div> </div>
</div> </div>
</div> </div>
+5 -1
View File
@@ -5,6 +5,7 @@ function EmptyState({
title = 'Нет данных', title = 'Нет данных',
description, description,
action, action,
secondaryAction,
}) { }) {
return ( return (
<div className="empty"> <div className="empty">
@@ -17,9 +18,12 @@ function EmptyState({
{description} {description}
</p> </p>
)} )}
{action && ( {(action || secondaryAction) && (
<div className="empty-action"> <div className="empty-action">
{action} {action}
{secondaryAction && (
<span className="ms-2">{secondaryAction}</span>
)}
</div> </div>
)} )}
</div> </div>
@@ -33,7 +33,7 @@ function PageHeaderActions({
onOnlineUpdate, onOnlineUpdate,
onBackgroundUpdate, onBackgroundUpdate,
}) { }) {
const secondaryButtons = Boolean(onImport || onExport || onClear || onClearCommunities || onHistory); const secondaryButtons = Boolean(onPreview || onImport || onExport || onClear || onClearCommunities || onHistory);
// Рендер плейсхолдеров во время загрузки/перезагрузки страницы // Рендер плейсхолдеров во время загрузки/перезагрузки страницы
if (loading) { if (loading) {
const showUpdateGroup = Boolean(onBackgroundUpdate || onOnlineUpdate); const showUpdateGroup = Boolean(onBackgroundUpdate || onOnlineUpdate);
@@ -176,6 +176,11 @@ function PageHeaderActions({
align="left" align="left"
buttonContent={<><IconDots className="me-1" /> Ещё</>} buttonContent={<><IconDots className="me-1" /> Ещё</>}
> >
{onPreview && (
<button className="dropdown-item" type="button" onClick={onPreview} disabled={disablePreview}>
<IconEye className="me-1" /> Предпросмотр
</button>
)}
{onRefresh && ( {onRefresh && (
<button className="dropdown-item" type="button" onClick={onRefresh} disabled={disableRefresh}> <button className="dropdown-item" type="button" onClick={onRefresh} disabled={disableRefresh}>
<IconRefresh className="me-1" /> Обновить <IconRefresh className="me-1" /> Обновить
+51
View File
@@ -3,3 +3,54 @@
/* Sticky footer helpers */ /* Sticky footer helpers */
.page { min-height: 100vh; display: flex; flex-direction: column; } .page { min-height: 100vh; display: flex; flex-direction: column; }
.footer { margin-top: auto; } .footer { margin-top: auto; }
/* ===== Tabler visual consistency (global overrides) ===== */
:root {
--app-gap-xs: .25rem;
--app-gap-sm: .5rem;
--app-gap-md: .75rem;
--app-gap-lg: 1rem;
}
/* Header actions under page header */
.page-header .header-actions {
display: flex;
flex-wrap: wrap;
gap: var(--app-gap-sm);
}
.page-header .header-actions .btn-group { margin-right: var(--app-gap-sm); }
.page-header .vr { margin: 0 var(--app-gap-sm); }
/* Buttons: icon spacing left */
.btn .icon { margin-right: .375rem; }
.btn.btn-icon .icon { margin-right: 0; }
/* Tables: remove hover background for rows (as per project preference) */
.table tbody tr:hover { background-color: transparent; }
/* Центровка контента ячеек и компактный вид */
.table.card-table th, .table.card-table td { vertical-align: middle; }
/* Compact card spacing */
.card.card-md { margin-bottom: var(--app-gap-lg); }
/* Utilities used in components */
.cursor-pointer { cursor: pointer; }
.border-dashed { border: 2px dashed rgba(0,0,0,.08); }
.transition { transition: all .2s ease; }
.rotate-180 { transform: rotate(180deg); }
/* Input icon alignment tweaks */
.input-icon .input-icon-addon { display: flex; align-items: center; }
/* Modal close button click area */
.modal .btn-close { position: absolute; right: .75rem; top: .75rem; }
/* Фильтры: удобная ширина селектов */
.form-select.w-auto { min-width: 180px; }
/* Икон-кнопки одинакового размера */
.btn.btn-icon.btn-sm { width: 2rem; height: 2rem; display: inline-flex; align-items: center; justify-content: center; }
/* Отступы между бейджами и в empty-action */
.badge + .badge { margin-left: .25rem; }
.empty .empty-action .btn + .btn { margin-left: .5rem; }