feat: Добавление статистических карточек и улучшение форм в компонентах ASNsNewManager, DomainsNewManager и IPRangesManager. Обновлены стили для улучшения пользовательского интерфейса и добавлены новые иконки для визуализации данных.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
This commit is contained in:
+238
-108
@@ -16,7 +16,10 @@ import {
|
|||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
IconHash,
|
IconHash,
|
||||||
IconClock,
|
IconClock,
|
||||||
IconFileText
|
IconFileText,
|
||||||
|
IconFilter,
|
||||||
|
IconArrowUp,
|
||||||
|
IconArrowDown
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||||
@@ -33,6 +36,7 @@ import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
|||||||
import ImportModal from './components/ImportModal.jsx';
|
import ImportModal from './components/ImportModal.jsx';
|
||||||
import QuickAddBar from './components/QuickAddBar.jsx';
|
import QuickAddBar from './components/QuickAddBar.jsx';
|
||||||
import AccordionCard from './components/AccordionCard.jsx';
|
import AccordionCard from './components/AccordionCard.jsx';
|
||||||
|
import SavedFilters from './components/SavedFilters.jsx';
|
||||||
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
@@ -487,12 +491,72 @@ function ASNsNewManager() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Статистические карточки */}
|
||||||
|
<div className="row g-3 mb-4">
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card card-sm animate-in">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className="bg-blue text-white avatar">
|
||||||
|
<IconHash size={24} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="font-weight-medium">Всего ASN</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
<span className="h2 m-0">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card card-sm animate-in" style={{animationDelay: '0.1s'}}>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className="bg-green text-white avatar">
|
||||||
|
<IconFilter size={24} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="font-weight-medium">Отфильтровано</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
<span className="h2 m-0">{filtered.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card card-sm animate-in" style={{animationDelay: '0.2s'}}>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className="bg-purple text-white avatar">
|
||||||
|
<IconHash size={24} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="font-weight-medium">Уникальных Community</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
<span className="h2 m-0">{allCommunities.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
{/* Classic Add New ASN Card (restored) */}
|
{/* Classic Add New ASN Card (improved) */}
|
||||||
<div className="card card-md">
|
<div className="card card-md animate-in">
|
||||||
<div className="card-header">
|
<div className="card-header bg-primary-lt">
|
||||||
<h3 className="card-title">
|
<h3 className="card-title text-primary">
|
||||||
<IconPlus className="icon me-2" />
|
<IconPlus className="icon me-2" />
|
||||||
Добавить новый ASN
|
Добавить новый ASN
|
||||||
</h3>
|
</h3>
|
||||||
@@ -500,46 +564,64 @@ function ASNsNewManager() {
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Номер AS</label>
|
<label className="form-label required">Номер AS</label>
|
||||||
<input
|
<div className="input-icon">
|
||||||
type="text"
|
<span className="input-icon-addon">
|
||||||
className={`form-control${newInvalid.asn ? ' is-invalid' : ''}`}
|
<IconHash size={18} />
|
||||||
placeholder="12345"
|
</span>
|
||||||
value={newItem.asn}
|
<input
|
||||||
onChange={(e) => setNewItem({ ...newItem, asn: e.target.value })}
|
type="text"
|
||||||
/>
|
className={`form-control${newInvalid.asn ? ' is-invalid' : isValidAsn(newItem.asn) ? ' is-valid' : ''}`}
|
||||||
|
placeholder="12345"
|
||||||
|
value={newItem.asn}
|
||||||
|
onChange={(e) => setNewItem({ ...newItem, asn: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{newInvalid.asn && <div className="invalid-feedback">Только цифры</div>}
|
{newInvalid.asn && <div className="invalid-feedback">Только цифры</div>}
|
||||||
<div className="form-text">Формат: только цифры, например 12345</div>
|
{isValidAsn(newItem.asn) && !newInvalid.asn && (<div className="valid-feedback">Корректный номер AS</div>)}
|
||||||
|
<div className="form-text text-muted small">Формат: только цифры, например 12345</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Community</label>
|
<label className="form-label required">Community</label>
|
||||||
<CommunityAutocompleteInput
|
<div className="input-icon">
|
||||||
value={newItem.community}
|
<span className="input-icon-addon">
|
||||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
<IconHash size={18} />
|
||||||
communities={communities}
|
</span>
|
||||||
placeholder="Начните вводить номер или имя"
|
<CommunityAutocompleteInput
|
||||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
value={newItem.community}
|
||||||
/>
|
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
||||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
|
communities={communities}
|
||||||
|
placeholder="Начните вводить номер или имя"
|
||||||
|
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
|
||||||
|
{isValidCommunity(newItem.community) && !newInvalid.community && (<div className="valid-feedback">Корректный формат community</div>)}
|
||||||
<div className="form-text">Формат: N или N:N (например 65000:100)</div>
|
<div className="form-text">Формат: N или N:N (например 65000:100)</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
const match = communities.find(c => c.value === String(newItem.community).trim());
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
return (
|
return (
|
||||||
<div className="form-text">
|
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
|
||||||
{match.name && (<><strong>{match.name}</strong> — </>)}
|
<div className="d-flex">
|
||||||
{match.description || ''}
|
<div>
|
||||||
{match.tags && match.tags.length > 0 && (
|
<strong>{match.name || match.value}</strong>
|
||||||
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
|
{match.description && <div className="text-muted small">{match.description}</div>}
|
||||||
)}
|
{match.tags && match.tags.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{match.tags.map((tag, idx) => (<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="form-footer">
|
<div className="form-footer">
|
||||||
<button type="submit" className="btn btn-primary w-100">
|
<button type="submit" className="btn btn-primary w-100" disabled={!isValidAsn(newItem.asn) || !isValidCommunity(newItem.community)}>
|
||||||
<IconPlus className="icon me-2" />
|
<IconPlus className="icon me-2" />
|
||||||
Добавить
|
Добавить ASN
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -547,7 +629,7 @@ function ASNsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Дополнительные варианты */}
|
{/* Дополнительные варианты */}
|
||||||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false}>
|
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
|
||||||
<QuickAddBar
|
<QuickAddBar
|
||||||
placeholder={'12345 65000:100'}
|
placeholder={'12345 65000:100'}
|
||||||
help={'Вставьте строки: ASN ПРОБЕЛ COMMUNITY'}
|
help={'Вставьте строки: ASN ПРОБЕЛ COMMUNITY'}
|
||||||
@@ -565,25 +647,20 @@ function ASNsNewManager() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="card card-md border-dashed mt-3"
|
<div className="card card-md border-dashed mt-3 animate-in"
|
||||||
onDragOver={(e) => { e.preventDefault(); }}
|
style={{animationDelay: '0.15s'}}
|
||||||
onDrop={onDropImport}
|
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
|
||||||
|
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
|
||||||
|
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
|
||||||
>
|
>
|
||||||
<div className="card-body text-center text-muted">
|
<div className="card-body text-center text-muted py-4">
|
||||||
Перетащите файл TXT/CSV сюда для импорта (формат: ASN community)
|
<IconUpload size={32} className="mb-2 text-muted" />
|
||||||
|
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
|
||||||
|
<div className="small">Формат: ASN community</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionCard>
|
</AccordionCard>
|
||||||
{/* Drag & Drop импорт */}
|
|
||||||
<div
|
|
||||||
className="card card-md border-dashed"
|
|
||||||
onDragOver={(e) => { e.preventDefault(); }}
|
|
||||||
onDrop={onDropImport}
|
|
||||||
>
|
|
||||||
<div className="card-body text-center text-muted">
|
|
||||||
Перетащите файл TXT/CSV сюда для импорта (формат: ASN community)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
||||||
</div>
|
</div>
|
||||||
@@ -592,10 +669,26 @@ function ASNsNewManager() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
||||||
<div className="d-flex gap-2 w-50">
|
<div className="d-flex gap-2 w-50 flex-wrap">
|
||||||
|
<SavedFilters
|
||||||
|
pageKey="asns"
|
||||||
|
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
|
||||||
|
onApplyFilter={(filters) => {
|
||||||
|
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
||||||
|
if (filters.filterCommunity !== undefined) setFilterCommunity(filters.filterCommunity);
|
||||||
|
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
||||||
|
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Фильтр по community */}
|
{/* Фильтр по community */}
|
||||||
<select
|
<div className="input-icon">
|
||||||
className="form-select w-auto"
|
<span className="input-icon-addon">
|
||||||
|
<IconFilter size={18} />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
style={{ minWidth: '200px' }}
|
||||||
value={filterCommunity}
|
value={filterCommunity}
|
||||||
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
||||||
title={(() => {
|
title={(() => {
|
||||||
@@ -616,9 +709,10 @@ function ASNsNewManager() {
|
|||||||
<option key={community} value={String(community)} title={title}>{label}</option>
|
<option key={community} value={String(community)} title={title}>{label}</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
{/* Поиск */}
|
{/* Поиск */}
|
||||||
<div className="input-icon flex-grow-1">
|
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
|
||||||
<span className="input-icon-addon">
|
<span className="input-icon-addon">
|
||||||
<IconSearch size={18} />
|
<IconSearch size={18} />
|
||||||
</span>
|
</span>
|
||||||
@@ -629,7 +723,19 @@ function ASNsNewManager() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||||
aria-label="Поиск ASN"
|
aria-label="Поиск ASN"
|
||||||
|
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
|
||||||
/>
|
/>
|
||||||
|
{!!searchTerm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||||||
|
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
|
||||||
|
aria-label="Очистить поиск"
|
||||||
|
onClick={() => { setSearchTerm(''); setCurrentPage(1); }}
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div></div>
|
<div></div>
|
||||||
@@ -657,32 +763,43 @@ function ASNsNewManager() {
|
|||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="cursor-pointer" onClick={() => handleSort('asn')}>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}>
|
||||||
Номер AS
|
<div className="d-flex align-items-center">
|
||||||
{sortField === 'asn' && (
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
<span className="ms-1">
|
Номер AS
|
||||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
{sortField === 'asn' && (
|
||||||
</span>
|
<span className="ms-1">
|
||||||
)}
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
Имя AS
|
Имя AS
|
||||||
</th>
|
</th>
|
||||||
<th className="cursor-pointer" onClick={() => handleSort('community')}>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
||||||
Community
|
<div className="d-flex align-items-center">
|
||||||
{sortField === 'community' && (
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
<span className="ms-1">
|
Community
|
||||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
{sortField === 'community' && (
|
||||||
</span>
|
<span className="ms-1">
|
||||||
)}
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-end"> </th>
|
<th className="text-end"> </th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedItems.map((item) => (
|
{paginatedItems.map((item, idx) => (
|
||||||
<tr key={item.asn} className={editingAsn === item.asn ? 'table-info' : ''}>
|
<tr key={item.asn} className={editingAsn === item.asn ? 'table-active' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
||||||
<td className="font-monospace" title="ASN">{item.asn}</td>
|
<td className="font-monospace" title="ASN">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconHash size={16} className="me-2 text-muted" />
|
||||||
|
<span>{item.asn}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td title={asnNameMap[item.asn] || ''}>
|
<td title={asnNameMap[item.asn] || ''}>
|
||||||
{asnNameMap[item.asn] ? (
|
{asnNameMap[item.asn] ? (
|
||||||
<span>{asnNameMap[item.asn]}</span>
|
<span>{asnNameMap[item.asn]}</span>
|
||||||
@@ -699,10 +816,11 @@ function ASNsNewManager() {
|
|||||||
onChange={setEditingValue}
|
onChange={setEditingValue}
|
||||||
communities={communities}
|
communities={communities}
|
||||||
onKeyDown={(e) => handleEditKeyDown(e, item.asn)}
|
onKeyDown={(e) => handleEditKeyDown(e, item.asn)}
|
||||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||||
|
style={{ width: '150px' }}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)} title="Сохранить (Enter)"><IconCheck size={16} /></button>
|
||||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit} title="Отмена (Esc)"><IconX size={16} /></button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -719,43 +837,55 @@ function ASNsNewManager() {
|
|||||||
)}
|
)}
|
||||||
{/* Пагинация */}
|
{/* Пагинация */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="card-footer d-flex align-items-center justify-content-between">
|
<div className="card-footer">
|
||||||
<div className="text-muted">
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || paginatedItems.length}
|
<div className="text-muted small">
|
||||||
|
Показано <strong>{((currentPage - 1) * pageSize) + 1}</strong> - <strong>{((currentPage - 1) * pageSize) + paginatedItems.length}</strong> из <strong>{totalItems || filtered.length}</strong>
|
||||||
|
</div>
|
||||||
|
<ul className="pagination m-0">
|
||||||
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1} aria-label="Первая страница">
|
||||||
|
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1} aria-label="Предыдущая страница">
|
||||||
|
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
|
<span className="d-none d-sm-inline ms-1">Назад</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{(() => {
|
||||||
|
const pages = [];
|
||||||
|
let start = Math.max(1, currentPage - 2);
|
||||||
|
let end = Math.min(totalPages, currentPage + 2);
|
||||||
|
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
||||||
|
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
||||||
|
if (start > 1) pages.push('start-ellipsis');
|
||||||
|
for (let p = start; p <= end; p++) pages.push(p);
|
||||||
|
if (end < totalPages) pages.push('end-ellipsis');
|
||||||
|
return pages.map((p) => (
|
||||||
|
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
||||||
|
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
||||||
|
) : (
|
||||||
|
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setCurrentPage(p)} aria-label={`Страница ${p}`} aria-current={currentPage === p ? 'page' : undefined}>{p}</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages} aria-label="Следующая страница">
|
||||||
|
<span className="d-none d-sm-inline me-1">Вперед</span>
|
||||||
|
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} aria-label="Последняя страница">
|
||||||
|
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<ul className="pagination m-0">
|
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>Первая</button>
|
|
||||||
</li>
|
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>Назад</button>
|
|
||||||
</li>
|
|
||||||
{(() => {
|
|
||||||
const pages = [];
|
|
||||||
let start = Math.max(1, currentPage - 2);
|
|
||||||
let end = Math.min(totalPages, currentPage + 2);
|
|
||||||
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
|
||||||
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
|
||||||
if (start > 1) pages.push('start-ellipsis');
|
|
||||||
for (let p = start; p <= end; p++) pages.push(p);
|
|
||||||
if (end < totalPages) pages.push('end-ellipsis');
|
|
||||||
return pages.map((p) => (
|
|
||||||
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
|
||||||
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
|
||||||
) : (
|
|
||||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>Вперед</button>
|
|
||||||
</li>
|
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}>Последняя</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<S3MetaBar keys={["asns"]} />
|
<S3MetaBar keys={["asns"]} />
|
||||||
|
|||||||
+387
-148
@@ -18,7 +18,12 @@ import {
|
|||||||
IconHash,
|
IconHash,
|
||||||
IconClock,
|
IconClock,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconFilter
|
IconFilter,
|
||||||
|
IconWorld,
|
||||||
|
IconTrendingUp,
|
||||||
|
IconUsers,
|
||||||
|
IconArrowUp,
|
||||||
|
IconArrowDown
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
@@ -530,12 +535,103 @@ function DomainsNewManager() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Статистические карточки */}
|
||||||
|
<div className="row g-3 mb-4">
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card card-sm animate-in">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className="bg-blue text-white avatar">
|
||||||
|
<IconWorld size={24} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="font-weight-medium">Всего доменов</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
<span className="h2 mb-0">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card card-sm animate-in" style={{animationDelay: '0.1s'}}>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className="bg-green text-white avatar">
|
||||||
|
<IconFilter size={24} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="font-weight-medium">Отфильтровано</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
<span className="h2 mb-0">{filtered.length}</span>
|
||||||
|
{filtered.length !== items.length && (
|
||||||
|
<span className="text-muted small ms-1">
|
||||||
|
({items.length - filtered.length} скрыто)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card card-sm animate-in" style={{animationDelay: '0.2s'}}>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className="bg-purple text-white avatar">
|
||||||
|
<IconUsers size={24} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="font-weight-medium">Уникальных Community</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
<span className="h2 mb-0">{allCommunities.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card card-sm animate-in" style={{animationDelay: '0.3s'}}>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className="bg-orange text-white avatar">
|
||||||
|
<IconTrendingUp size={24} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="font-weight-medium">Выбрано</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
<span className="h2 mb-0">{selectedDomains.size}</span>
|
||||||
|
{selectedDomains.size > 0 && (
|
||||||
|
<span className="text-muted small ms-1">
|
||||||
|
({Math.round((selectedDomains.size / filtered.length) * 100)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
{/* Classic Add New Domain Card (restored) */}
|
{/* Classic Add New Domain Card (improved) */}
|
||||||
<div className="card card-md animate-in">
|
<div className="card card-md animate-in">
|
||||||
<div className="card-header">
|
<div className="card-header bg-primary-lt">
|
||||||
<h3 className="card-title">
|
<h3 className="card-title text-primary">
|
||||||
<IconPlus className="icon me-2" />
|
<IconPlus className="icon me-2" />
|
||||||
Добавить новый домен
|
Добавить новый домен
|
||||||
</h3>
|
</h3>
|
||||||
@@ -543,47 +639,78 @@ function DomainsNewManager() {
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Домен</label>
|
<label className="form-label required">Домен</label>
|
||||||
<input
|
<div className="input-icon">
|
||||||
type="text"
|
<span className="input-icon-addon">
|
||||||
className={`form-control${newInvalid.domain ? ' is-invalid' : ''}`}
|
<IconWorld size={18} />
|
||||||
placeholder="example.com"
|
</span>
|
||||||
value={newItem.domain}
|
<input
|
||||||
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
|
type="text"
|
||||||
/>
|
className={`form-control${newInvalid.domain ? ' is-invalid' : isValidDomain(newItem.domain) ? ' is-valid' : ''}`}
|
||||||
|
placeholder="example.com"
|
||||||
|
value={newItem.domain}
|
||||||
|
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{newInvalid.domain && <div className="invalid-feedback">Неверный формат домена</div>}
|
{newInvalid.domain && <div className="invalid-feedback">Неверный формат домена</div>}
|
||||||
<div className="form-text">Без http/https, только доменное имя</div>
|
{isValidDomain(newItem.domain) && !newInvalid.domain && (
|
||||||
<div className="form-text">Формат: sub.domain.tld (например foo.example.org)</div>
|
<div className="valid-feedback">Корректный формат домена</div>
|
||||||
|
)}
|
||||||
|
<div className="form-text text-muted small">
|
||||||
|
Формат: sub.domain.tld (например foo.example.org)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Community</label>
|
<label className="form-label required">Community</label>
|
||||||
<CommunityAutocompleteInput
|
<div className="input-icon">
|
||||||
value={newItem.community}
|
<span className="input-icon-addon">
|
||||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
<IconHash size={18} />
|
||||||
communities={communities}
|
</span>
|
||||||
placeholder="Начните вводить номер или имя"
|
<CommunityAutocompleteInput
|
||||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
value={newItem.community}
|
||||||
/>
|
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
||||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
|
communities={communities}
|
||||||
<div className="form-text">Формат: N или N:N (например 65000:100)</div>
|
placeholder="Начните вводить номер или имя"
|
||||||
|
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
|
||||||
|
{isValidCommunity(newItem.community) && !newInvalid.community && (
|
||||||
|
<div className="valid-feedback">Корректный формат community</div>
|
||||||
|
)}
|
||||||
|
<div className="form-text text-muted small">
|
||||||
|
Формат: N или N:N (например 65000:100)
|
||||||
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
const match = communities.find(c => c.value === String(newItem.community).trim());
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
return (
|
return (
|
||||||
<div className="form-text">
|
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
|
||||||
{match.name && (<><strong>{match.name}</strong> — </>)}
|
<div className="d-flex">
|
||||||
{match.description || ''}
|
<div>
|
||||||
{match.tags && match.tags.length > 0 && (
|
<strong>{match.name || match.value}</strong>
|
||||||
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
|
{match.description && <div className="text-muted small">{match.description}</div>}
|
||||||
)}
|
{match.tags && match.tags.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{match.tags.map((tag, idx) => (
|
||||||
|
<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="form-footer">
|
<div className="form-footer">
|
||||||
<button type="submit" className="btn btn-primary w-100">
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-100"
|
||||||
|
disabled={!isValidDomain(newItem.domain) || !isValidCommunity(newItem.community)}
|
||||||
|
>
|
||||||
<IconPlus className="icon me-2" />
|
<IconPlus className="icon me-2" />
|
||||||
Добавить
|
Добавить домен
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -609,28 +736,20 @@ function DomainsNewManager() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="card card-md border-dashed mt-3"
|
<div
|
||||||
onDragOver={(e) => { e.preventDefault(); }}
|
className="card card-md border-dashed mt-3 animate-in"
|
||||||
onDrop={onDropImport}
|
style={{animationDelay: '0.15s'}}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
|
||||||
|
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
|
||||||
|
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
|
||||||
>
|
>
|
||||||
<div className="card-body text-center text-muted">
|
<div className="card-body text-center text-muted py-4">
|
||||||
Перетащите файл TXT/CSV сюда для импорта (формат: domain community)
|
<IconUpload size={32} className="mb-2 text-muted" />
|
||||||
|
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
|
||||||
|
<div className="small">Формат: domain community</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionCard>
|
</AccordionCard>
|
||||||
|
|
||||||
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
|
||||||
{/* Drag & Drop импорт */}
|
|
||||||
<div
|
|
||||||
className="card card-md border-dashed animate-in"
|
|
||||||
style={{animationDelay: '0.2s'}}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); }}
|
|
||||||
onDrop={onDropImport}
|
|
||||||
>
|
|
||||||
<div className="card-body text-center text-muted">
|
|
||||||
Перетащите файл TXT/CSV сюда для импорта (формат: domain community)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-lg-9">
|
<div className="col-lg-9">
|
||||||
@@ -646,15 +765,16 @@ function DomainsNewManager() {
|
|||||||
|
|
||||||
<div className="card animate-in" style={{animationDelay: '0.3s'}}>
|
<div className="card animate-in" style={{animationDelay: '0.3s'}}>
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="row align-items-center w-100">
|
<div className="row align-items-center w-100 g-2">
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<h3 className="card-title mb-0">
|
<h3 className="card-title mb-0">
|
||||||
|
<IconWorld className="icon me-2" size={20} />
|
||||||
Список доменов
|
Список доменов
|
||||||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="col">
|
<div className="col">
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2 flex-wrap">
|
||||||
{/* Сохранённые фильтры */}
|
{/* Сохранённые фильтры */}
|
||||||
<SavedFilters
|
<SavedFilters
|
||||||
pageKey="domains"
|
pageKey="domains"
|
||||||
@@ -669,32 +789,38 @@ function DomainsNewManager() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Фильтр по community */}
|
{/* Фильтр по community */}
|
||||||
<select
|
<div className="input-icon">
|
||||||
className="form-select w-auto"
|
<span className="input-icon-addon">
|
||||||
value={filterCommunity}
|
<IconFilter size={18} />
|
||||||
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
</span>
|
||||||
title={(() => {
|
<select
|
||||||
const meta = communities.find(c => c.value === filterCommunity);
|
className="form-select"
|
||||||
if (!meta) return 'Все community';
|
style={{ minWidth: '200px' }}
|
||||||
const name = meta.name ? meta.name + ' — ' : '';
|
value={filterCommunity}
|
||||||
const desc = meta.description || '';
|
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
|
||||||
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
|
title={(() => {
|
||||||
return name + desc + tags;
|
const meta = communities.find(c => c.value === filterCommunity);
|
||||||
})()}
|
if (!meta) return 'Все community';
|
||||||
>
|
const name = meta.name ? meta.name + ' — ' : '';
|
||||||
<option value="">Все community</option>
|
const desc = meta.description || '';
|
||||||
{allCommunities.map(community => {
|
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
|
||||||
const meta = communities.find(c => c.value === community);
|
return name + desc + tags;
|
||||||
const label = meta && meta.name ? `${community} — ${meta.name}` : community;
|
})()}
|
||||||
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
|
>
|
||||||
return (
|
<option value="">Все community</option>
|
||||||
<option key={community} value={community} title={title}>{label}</option>
|
{allCommunities.map(community => {
|
||||||
);
|
const meta = communities.find(c => c.value === community);
|
||||||
})}
|
const label = meta && meta.name ? `${community} — ${meta.name}` : community;
|
||||||
</select>
|
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
|
||||||
|
return (
|
||||||
|
<option key={community} value={community} title={title}>{label}</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Поиск */}
|
{/* Поиск */}
|
||||||
<div className="input-icon flex-grow-1">
|
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
|
||||||
<span className="input-icon-addon">
|
<span className="input-icon-addon">
|
||||||
<IconSearch size={18} />
|
<IconSearch size={18} />
|
||||||
</span>
|
</span>
|
||||||
@@ -705,7 +831,25 @@ function DomainsNewManager() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||||
aria-label="Поиск домена"
|
aria-label="Поиск домена"
|
||||||
|
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
|
||||||
/>
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '0.25rem',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
zIndex: 2
|
||||||
|
}}
|
||||||
|
onClick={() => { setSearchTerm(''); setCurrentPage(1); }}
|
||||||
|
aria-label="Очистить поиск"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -742,57 +886,111 @@ function DomainsNewManager() {
|
|||||||
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
|
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th className="cursor-pointer" onClick={() => handleSort('domain')}>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
|
||||||
Домен
|
<div className="d-flex align-items-center">
|
||||||
{sortField === 'domain' && (
|
<IconWorld size={16} className="me-1 text-muted" />
|
||||||
<span className="ms-1">
|
Домен
|
||||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
{sortField === 'domain' && (
|
||||||
</span>
|
<span className="ms-1">
|
||||||
)}
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="cursor-pointer" onClick={() => handleSort('community')}>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
||||||
Community
|
<div className="d-flex align-items-center">
|
||||||
{sortField === 'community' && (
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
<span className="ms-1">
|
Community
|
||||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
{sortField === 'community' && (
|
||||||
</span>
|
<span className="ms-1">
|
||||||
)}
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-end"> </th>
|
<th className="text-end"> </th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedItems.map((item) => (
|
{paginatedItems.map((item, idx) => (
|
||||||
<tr key={item.domain} className={editingDomain === item.domain ? 'table-info' : ''}>
|
<tr
|
||||||
|
key={item.domain}
|
||||||
|
className={editingDomain === item.domain ? 'table-active' : ''}
|
||||||
|
style={{
|
||||||
|
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
|
||||||
|
}}
|
||||||
|
>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="form-check-input"
|
className="form-check-input"
|
||||||
checked={selectedDomains.has(item.domain)}
|
checked={selectedDomains.has(item.domain)}
|
||||||
onChange={() => handleToggleSelect(item.domain)}
|
onChange={() => handleToggleSelect(item.domain)}
|
||||||
|
aria-label={`Выбрать ${item.domain}`}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>{item.domain}</td>
|
<td>
|
||||||
<td>{renderCommunityBadge(item.community)}</td>
|
<div className="d-flex align-items-center">
|
||||||
|
<IconWorld size={16} className="me-2 text-muted" />
|
||||||
|
<span className="font-monospace">{item.domain}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{renderCommunityBadge(item.community)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{editingDomain === item.domain ? (
|
<div className="btn-group">
|
||||||
<>
|
{editingDomain === item.domain ? (
|
||||||
<CommunityAutocompleteInput
|
<>
|
||||||
value={editingValue}
|
<CommunityAutocompleteInput
|
||||||
onChange={setEditingValue}
|
value={editingValue}
|
||||||
communities={communities}
|
onChange={setEditingValue}
|
||||||
onKeyDown={(e) => handleEditKeyDown(e, item.domain)}
|
communities={communities}
|
||||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
onKeyDown={(e) => handleEditKeyDown(e, item.domain)}
|
||||||
/>
|
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.domain)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
style={{ width: '150px' }}
|
||||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
/>
|
||||||
</>
|
<button
|
||||||
) : (
|
className="btn btn-success btn-icon btn-sm"
|
||||||
<>
|
onClick={() => handleSaveEdit(item.domain)}
|
||||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
disabled={!isValidCommunity(editingValue)}
|
||||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
aria-label="Сохранить"
|
||||||
</>
|
title="Сохранить (Enter)"
|
||||||
)}
|
>
|
||||||
|
<IconCheck size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-icon btn-sm"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
aria-label="Отмена"
|
||||||
|
title="Отмена (Esc)"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary btn-icon btn-sm"
|
||||||
|
aria-label="Редактировать"
|
||||||
|
onClick={() => handleEdit(item)}
|
||||||
|
title="Редактировать community"
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger btn-icon btn-sm"
|
||||||
|
aria-label="Удалить"
|
||||||
|
onClick={() => confirmDelete(item)}
|
||||||
|
title="Удалить домен"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -802,43 +1000,84 @@ function DomainsNewManager() {
|
|||||||
)}
|
)}
|
||||||
{/* Пагинация */}
|
{/* Пагинация */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="card-footer d-flex align-items-center justify-content-between">
|
<div className="card-footer">
|
||||||
<div className="text-muted">
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || paginatedItems.length}
|
<div className="text-muted small">
|
||||||
|
Показано <strong>{((currentPage - 1) * pageSize) + 1}</strong> - <strong>{((currentPage - 1) * pageSize) + paginatedItems.length}</strong> из <strong>{totalItems || filtered.length}</strong>
|
||||||
|
</div>
|
||||||
|
<ul className="pagination m-0">
|
||||||
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Первая страница"
|
||||||
|
>
|
||||||
|
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => setCurrentPage(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Предыдущая страница"
|
||||||
|
>
|
||||||
|
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
|
<span className="d-none d-sm-inline ms-1">Назад</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{(() => {
|
||||||
|
const pages = [];
|
||||||
|
let start = Math.max(1, currentPage - 2);
|
||||||
|
let end = Math.min(totalPages, currentPage + 2);
|
||||||
|
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
||||||
|
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
||||||
|
if (start > 1) pages.push('start-ellipsis');
|
||||||
|
for (let p = start; p <= end; p++) pages.push(p);
|
||||||
|
if (end < totalPages) pages.push('end-ellipsis');
|
||||||
|
return pages.map((p) => (
|
||||||
|
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
||||||
|
<li key={p} className="page-item disabled">
|
||||||
|
<span className="page-link">…</span>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => setCurrentPage(p)}
|
||||||
|
aria-label={`Страница ${p}`}
|
||||||
|
aria-current={currentPage === p ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => setCurrentPage(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
aria-label="Следующая страница"
|
||||||
|
>
|
||||||
|
<span className="d-none d-sm-inline me-1">Вперед</span>
|
||||||
|
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
aria-label="Последняя страница"
|
||||||
|
>
|
||||||
|
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<ul className="pagination m-0">
|
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>Первая</button>
|
|
||||||
</li>
|
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>Назад</button>
|
|
||||||
</li>
|
|
||||||
{(() => {
|
|
||||||
const pages = [];
|
|
||||||
let start = Math.max(1, currentPage - 2);
|
|
||||||
let end = Math.min(totalPages, currentPage + 2);
|
|
||||||
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
|
||||||
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
|
||||||
if (start > 1) pages.push('start-ellipsis');
|
|
||||||
for (let p = start; p <= end; p++) pages.push(p);
|
|
||||||
if (end < totalPages) pages.push('end-ellipsis');
|
|
||||||
return pages.map((p) => (
|
|
||||||
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
|
||||||
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
|
||||||
) : (
|
|
||||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>Вперед</button>
|
|
||||||
</li>
|
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}>Последняя</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<S3MetaBar keys={["domainsNew"]} />
|
<S3MetaBar keys={["domainsNew"]} />
|
||||||
|
|||||||
+205
-108
@@ -16,7 +16,11 @@ import {
|
|||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
IconHash,
|
IconHash,
|
||||||
IconClock,
|
IconClock,
|
||||||
IconFileText
|
IconFileText,
|
||||||
|
IconFilter,
|
||||||
|
IconArrowUp,
|
||||||
|
IconArrowDown,
|
||||||
|
IconNetwork
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||||||
import S3MetaBar from './components/S3MetaBar.jsx';
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
||||||
@@ -33,6 +37,7 @@ import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
|||||||
import ImportModal from './components/ImportModal.jsx';
|
import ImportModal from './components/ImportModal.jsx';
|
||||||
import QuickAddBar from './components/QuickAddBar.jsx';
|
import QuickAddBar from './components/QuickAddBar.jsx';
|
||||||
import AccordionCard from './components/AccordionCard.jsx';
|
import AccordionCard from './components/AccordionCard.jsx';
|
||||||
|
import SavedFilters from './components/SavedFilters.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -740,12 +745,72 @@ function IPRangesManager() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Статистические карточки */}
|
||||||
|
<div className="row g-3 mb-4">
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card card-sm animate-in">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className="bg-blue text-white avatar">
|
||||||
|
<IconNetwork size={24} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="font-weight-medium">Всего диапазонов</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
<span className="h2 m-0">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card card-sm animate-in" style={{animationDelay: '0.1s'}}>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className="bg-green text-white avatar">
|
||||||
|
<IconFilter size={24} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="font-weight-medium">Отфильтровано</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
<span className="h2 m-0">{filtered.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card card-sm animate-in" style={{animationDelay: '0.2s'}}>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className="bg-purple text-white avatar">
|
||||||
|
<IconHash size={24} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="font-weight-medium">Уникальных Community</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
<span className="h2 m-0">{allCommunities.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
{/* Classic Add New Item Card (restored) */}
|
{/* Classic Add New Item Card (improved) */}
|
||||||
<div className="card card-md">
|
<div className="card card-md animate-in">
|
||||||
<div className="card-header">
|
<div className="card-header bg-primary-lt">
|
||||||
<h3 className="card-title">
|
<h3 className="card-title text-primary">
|
||||||
<IconPlus className="icon me-2" />
|
<IconPlus className="icon me-2" />
|
||||||
Добавить новый IP-диапазон
|
Добавить новый IP-диапазон
|
||||||
</h3>
|
</h3>
|
||||||
@@ -753,45 +818,63 @@ function IPRangesManager() {
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">IP-диапазон</label>
|
<label className="form-label required">IP-диапазон</label>
|
||||||
<input
|
<div className="input-icon">
|
||||||
type="text"
|
<span className="input-icon-addon">
|
||||||
className={`form-control${newInvalid.ipRange ? ' is-invalid' : ''}`}
|
<IconNetwork size={18} />
|
||||||
placeholder="192.168.1.0/24"
|
</span>
|
||||||
value={newItem.ipRange}
|
<input
|
||||||
onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
|
type="text"
|
||||||
/>
|
className={`form-control${newInvalid.ipRange ? ' is-invalid' : isValidCidr(newItem.ipRange) ? ' is-valid' : ''}`}
|
||||||
|
placeholder="192.168.1.0/24"
|
||||||
|
value={newItem.ipRange}
|
||||||
|
onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{newInvalid.ipRange && <div className="invalid-feedback">Некорректный CIDR</div>}
|
{newInvalid.ipRange && <div className="invalid-feedback">Некорректный CIDR</div>}
|
||||||
<div className="form-text">Формат: A.B.C.D/Маска, например 10.0.0.0/24</div>
|
{isValidCidr(newItem.ipRange) && !newInvalid.ipRange && (<div className="valid-feedback">Корректный CIDR</div>)}
|
||||||
|
<div className="form-text text-muted small">Формат: A.B.C.D/Маска, например 10.0.0.0/24</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Community</label>
|
<label className="form-label required">Community</label>
|
||||||
<CommunityAutocompleteInput
|
<div className="input-icon">
|
||||||
value={newItem.community}
|
<span className="input-icon-addon">
|
||||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
<IconHash size={18} />
|
||||||
communities={communities}
|
</span>
|
||||||
placeholder="Начните вводить номер или имя"
|
<CommunityAutocompleteInput
|
||||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
value={newItem.community}
|
||||||
/>
|
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
||||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
|
communities={communities}
|
||||||
|
placeholder="Начните вводить номер или имя"
|
||||||
|
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
|
||||||
|
{isValidCommunity(newItem.community) && !newInvalid.community && (<div className="valid-feedback">Корректный формат community</div>)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
const match = communities.find(c => c.value === String(newItem.community).trim());
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
return (
|
return (
|
||||||
<div className="form-text">
|
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
|
||||||
{match.name && (<><strong>{match.name}</strong> — </>)}
|
<div className="d-flex">
|
||||||
{match.description || ''}
|
<div>
|
||||||
{match.tags && match.tags.length > 0 && (
|
<strong>{match.name || match.value}</strong>
|
||||||
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
|
{match.description && <div className="text-muted small">{match.description}</div>}
|
||||||
)}
|
{match.tags && match.tags.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{match.tags.map((tag, idx) => (<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="form-footer">
|
<div className="form-footer">
|
||||||
<button type="submit" className="btn btn-primary w-100">
|
<button type="submit" className="btn btn-primary w-100" disabled={!isValidCidr(newItem.ipRange) || !isValidCommunity(newItem.community)}>
|
||||||
<IconPlus className="icon me-2" />
|
<IconPlus className="icon me-2" />
|
||||||
Добавить
|
Добавить диапазон
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -799,7 +882,7 @@ function IPRangesManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Дополнительные варианты */}
|
{/* Дополнительные варианты */}
|
||||||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false}>
|
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
|
||||||
<QuickAddBar
|
<QuickAddBar
|
||||||
placeholder={'192.168.1.0/24 65000:100'}
|
placeholder={'192.168.1.0/24 65000:100'}
|
||||||
help={'Вставьте строки: CIDR ПРОБЕЛ COMMUNITY'}
|
help={'Вставьте строки: CIDR ПРОБЕЛ COMMUNITY'}
|
||||||
@@ -817,25 +900,20 @@ function IPRangesManager() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="card card-md border-dashed mt-3"
|
<div className="card card-md border-dashed mt-3 animate-in"
|
||||||
onDragOver={(e) => { e.preventDefault(); }}
|
style={{animationDelay: '0.15s'}}
|
||||||
onDrop={onDropImport}
|
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
|
||||||
|
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
|
||||||
|
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
|
||||||
>
|
>
|
||||||
<div className="card-body text-center text-muted">
|
<div className="card-body text-center text-muted py-4">
|
||||||
Перетащите файл TXT/CSV сюда для импорта (формат: CIDR community)
|
<IconUpload size={32} className="mb-2 text-muted" />
|
||||||
|
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
|
||||||
|
<div className="small">Формат: CIDR community</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionCard>
|
</AccordionCard>
|
||||||
{/* Drag & Drop импорт */}
|
|
||||||
<div
|
|
||||||
className="card card-md border-dashed"
|
|
||||||
onDragOver={(e) => { e.preventDefault(); }}
|
|
||||||
onDrop={onDropImport}
|
|
||||||
>
|
|
||||||
<div className="card-body text-center text-muted">
|
|
||||||
Перетащите файл TXT/CSV сюда для импорта (формат: CIDR community)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
||||||
</div>
|
</div>
|
||||||
@@ -908,29 +986,40 @@ function IPRangesManager() {
|
|||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="cursor-pointer" onClick={() => handleSort('ipRange')}>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}>
|
||||||
IP-диапазон
|
<div className="d-flex align-items-center">
|
||||||
{sortField === 'ipRange' && (
|
<IconNetwork size={16} className="me-1 text-muted" />
|
||||||
<span className="ms-1">
|
IP-диапазон
|
||||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
{sortField === 'ipRange' && (
|
||||||
</span>
|
<span className="ms-1">
|
||||||
)}
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="cursor-pointer" onClick={() => handleSort('community')}>
|
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
|
||||||
Community
|
<div className="d-flex align-items-center">
|
||||||
{sortField === 'community' && (
|
<IconHash size={16} className="me-1 text-muted" />
|
||||||
<span className="ms-1">
|
Community
|
||||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
{sortField === 'community' && (
|
||||||
</span>
|
<span className="ms-1">
|
||||||
)}
|
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-end"> </th>
|
<th className="text-end"> </th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedItems.map((item) => (
|
{paginatedItems.map((item, idx) => (
|
||||||
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-info' : ''}>
|
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-active' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
||||||
<td className="font-monospace" title="CIDR">{item.ipRange}</td>
|
<td className="font-monospace" title="CIDR">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<IconNetwork size={16} className="me-2 text-muted" />
|
||||||
|
<span>{item.ipRange}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>{renderCommunityBadge(item.community)}</td>
|
<td>{renderCommunityBadge(item.community)}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{editingIp === item.ipRange ? (
|
{editingIp === item.ipRange ? (
|
||||||
@@ -939,11 +1028,12 @@ function IPRangesManager() {
|
|||||||
value={editingValue}
|
value={editingValue}
|
||||||
onChange={setEditingValue}
|
onChange={setEditingValue}
|
||||||
communities={communities}
|
communities={communities}
|
||||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||||
|
style={{ width: '150px' }}
|
||||||
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
|
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)} title="Сохранить (Enter)"><IconCheck size={16} /></button>
|
||||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit} title="Отмена (Esc)"><IconX size={16} /></button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -960,48 +1050,55 @@ function IPRangesManager() {
|
|||||||
)}
|
)}
|
||||||
{/* Пагинация */}
|
{/* Пагинация */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="card-footer d-flex align-items-center justify-content-between">
|
<div className="card-footer">
|
||||||
<div className="text-muted">
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || paginatedItems.length}
|
<div className="text-muted small">
|
||||||
</div>
|
Показано <strong>{((currentPage - 1) * pageSize) + 1}</strong> - <strong>{((currentPage - 1) * pageSize) + paginatedItems.length}</strong> из <strong>{totalItems || filtered.length}</strong>
|
||||||
<ul className="pagination m-0">
|
</div>
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
<ul className="pagination m-0">
|
||||||
<button className="page-link" onClick={() => setCurrentPage(1)} tabIndex={-1} disabled={currentPage === 1}>Первая</button>
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||||
</li>
|
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1} aria-label="Первая страница">
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} tabIndex={-1} disabled={currentPage === 1}>Назад</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{(() => {
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||||
const pages = [];
|
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1} aria-label="Предыдущая страница">
|
||||||
const maxToShow = 7; // 1 .. 5 .. N
|
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
let start = Math.max(1, currentPage - 2);
|
<span className="d-none d-sm-inline ms-1">Назад</span>
|
||||||
let end = Math.min(totalPages, currentPage + 2);
|
</button>
|
||||||
if (currentPage <= 3) { end = Math.min(totalPages, 5); }
|
</li>
|
||||||
if (currentPage >= totalPages - 2) { start = Math.max(1, totalPages - 4); }
|
{(() => {
|
||||||
if (start > 1) pages.push('start-ellipsis');
|
const pages = [];
|
||||||
for (let p = start; p <= end; p++) pages.push(p);
|
let start = Math.max(1, currentPage - 2);
|
||||||
if (end < totalPages) pages.push('end-ellipsis');
|
let end = Math.min(totalPages, currentPage + 2);
|
||||||
const renderItem = (p) => {
|
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
||||||
if (p === 'start-ellipsis' || p === 'end-ellipsis') {
|
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
||||||
return (
|
if (start > 1) pages.push('start-ellipsis');
|
||||||
|
for (let p = start; p <= end; p++) pages.push(p);
|
||||||
|
if (end < totalPages) pages.push('end-ellipsis');
|
||||||
|
return pages.map((p) => (
|
||||||
|
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
||||||
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
||||||
);
|
) : (
|
||||||
}
|
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
||||||
return (
|
<button className="page-link" onClick={() => setCurrentPage(p)} aria-label={`Страница ${p}`} aria-current={currentPage === p ? 'page' : undefined}>{p}</button>
|
||||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
</li>
|
||||||
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
|
)
|
||||||
</li>
|
));
|
||||||
);
|
})()}
|
||||||
};
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||||
return pages.map(renderItem);
|
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages} aria-label="Следующая страница">
|
||||||
})()}
|
<span className="d-none d-sm-inline me-1">Вперед</span>
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} tabIndex={-1} disabled={currentPage === totalPages}>Вперед</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||||
<button className="page-link" onClick={() => setCurrentPage(totalPages)} tabIndex={-1} disabled={currentPage === totalPages}>Последняя</button>
|
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} aria-label="Последняя страница">
|
||||||
</li>
|
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||||
</ul>
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<S3MetaBar keys={["ipRanges"]} />
|
<S3MetaBar keys={["ipRanges"]} />
|
||||||
|
|||||||
@@ -216,4 +216,140 @@ button:focus-visible,
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, -50%) scale(1.05);
|
transform: translate(-50%, -50%) scale(1.05);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Animations ===== */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-in {
|
||||||
|
animation: fadeIn 0.4s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Statistics Cards ===== */
|
||||||
|
.card-sm .avatar {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--tblr-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sm .h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Form Improvements ===== */
|
||||||
|
.form-label.required::after {
|
||||||
|
content: ' *';
|
||||||
|
color: var(--tblr-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon .input-icon-addon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--tblr-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon .form-control,
|
||||||
|
.input-icon .form-select {
|
||||||
|
padding-left: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon .form-control:focus ~ .input-icon-addon,
|
||||||
|
.input-icon .form-select:focus ~ .input-icon-addon {
|
||||||
|
color: var(--tblr-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка очистки поиска */
|
||||||
|
.input-icon .btn-icon {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.25rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Table Improvements ===== */
|
||||||
|
.table thead th {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--tblr-muted);
|
||||||
|
border-bottom: 2px solid var(--tblr-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--tblr-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr.table-active {
|
||||||
|
background-color: rgba(32, 107, 196, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-select-none {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Pagination Improvements ===== */
|
||||||
|
.pagination .page-link {
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item.active .page-link {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Card Header Improvements ===== */
|
||||||
|
.card-header.bg-primary-lt {
|
||||||
|
background-color: rgba(32, 107, 196, 0.1);
|
||||||
|
border-bottom: 1px solid rgba(32, 107, 196, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Responsive Improvements ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-sm .avatar {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sm .h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user