feat: Enhance ASNsNewManager with input validation, import/export functionality, and improved UI feedback for better user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 7m32s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 7m32s
This commit is contained in:
@@ -1,14 +1,17 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconX,
|
IconX,
|
||||||
IconDatabase,
|
IconDatabase,
|
||||||
IconRefresh
|
IconRefresh,
|
||||||
|
IconUpload,
|
||||||
|
IconDownload,
|
||||||
|
IconDeviceFloppy
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
@@ -16,6 +19,7 @@ const API_URL = '/api';
|
|||||||
function ASNsNewManager() {
|
function ASNsNewManager() {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [newItem, setNewItem] = useState({ asn: '', community: '' });
|
const [newItem, setNewItem] = useState({ asn: '', community: '' });
|
||||||
|
const [newInvalid, setNewInvalid] = useState({ asn: false, community: false });
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -31,6 +35,9 @@ function ASNsNewManager() {
|
|||||||
const editInputRef = useRef(null);
|
const editInputRef = useRef(null);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
|
const isValidAsn = (value) => /^[0-9]+$/.test(String(value).trim());
|
||||||
|
const isValidCommunity = (value) => /^[0-9]+$/.test(String(value).trim());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchItems();
|
fetchItems();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -50,17 +57,17 @@ function ASNsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
if (newItem.asn.trim() === '') {
|
const asnOk = isValidAsn(newItem.asn);
|
||||||
setError('Номер AS не может быть пустым.');
|
const communityOk = isValidCommunity(newItem.community);
|
||||||
return;
|
setNewInvalid({ asn: !asnOk, community: !communityOk });
|
||||||
}
|
if (!asnOk || !communityOk) {
|
||||||
if (newItem.community.trim() === '') {
|
setError('Введите корректный номер ASN и числовой community.');
|
||||||
setError('Community не может быть пустым.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError('');
|
setError('');
|
||||||
setItems([...items, newItem]);
|
setItems([...items, { asn: String(newItem.asn).trim(), community: String(newItem.community).trim() }]);
|
||||||
setNewItem({ asn: '', community: '' });
|
setNewItem({ asn: '', community: '' });
|
||||||
|
setNewInvalid({ asn: false, community: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (item) => {
|
const handleEdit = (item) => {
|
||||||
@@ -69,8 +76,12 @@ function ASNsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEdit = (asn) => {
|
const handleSaveEdit = (asn) => {
|
||||||
|
if (!isValidCommunity(editingValue)) {
|
||||||
|
setError('Community должен быть числом.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const updatedItems = items.map(i =>
|
const updatedItems = items.map(i =>
|
||||||
i.asn === asn ? { ...i, community: editingValue } : i
|
i.asn === asn ? { ...i, community: String(editingValue).trim() } : i
|
||||||
);
|
);
|
||||||
setItems(updatedItems);
|
setItems(updatedItems);
|
||||||
setEditingAsn(null);
|
setEditingAsn(null);
|
||||||
@@ -101,7 +112,8 @@ function ASNsNewManager() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// API ожидает domains: [{domain, type}]
|
// API ожидает domains: [{domain, type}]
|
||||||
await axios.post(`${API_URL}/asns`, { domains: items.map(i => ({ domain: i.asn, type: i.community })) });
|
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community));
|
||||||
|
await axios.post(`${API_URL}/asns`, { domains: valid.map(i => ({ domain: i.asn, type: i.community })) });
|
||||||
setSuccess('Изменения успешно сохранены!');
|
setSuccess('Изменения успешно сохранены!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -112,6 +124,37 @@ function ASNsNewManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
const text = window.prompt('Вставьте строки: ASN ПРОБЕЛ COMMUNITY (по одной записи на строку)');
|
||||||
|
if (!text) return;
|
||||||
|
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||||
|
const parsed = lines.map(l => {
|
||||||
|
const [a, c] = l.split(/\s+/);
|
||||||
|
return { asn: a || '', community: c || '' };
|
||||||
|
});
|
||||||
|
setItems(prev => [...prev, ...parsed]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const header = ['asn', 'community'];
|
||||||
|
const csv = [header, ...items.map(i => [i.asn, i.community])]
|
||||||
|
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `asns_${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearInvalid = () => {
|
||||||
|
setItems(prev => prev.filter(i => i.asn || i.community)
|
||||||
|
.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Сортировка
|
// Сортировка
|
||||||
const sortedItems = [...items].sort((a, b) => {
|
const sortedItems = [...items].sort((a, b) => {
|
||||||
let valA = a[sortField] || '';
|
let valA = a[sortField] || '';
|
||||||
@@ -208,21 +251,23 @@ function ASNsNewManager() {
|
|||||||
<label className="form-label">Номер AS</label>
|
<label className="form-label">Номер AS</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className={`form-control${newInvalid.asn ? ' is-invalid' : ''}`}
|
||||||
placeholder="12345"
|
placeholder="12345"
|
||||||
value={newItem.asn}
|
value={newItem.asn}
|
||||||
onChange={(e) => setNewItem({ ...newItem, asn: e.target.value })}
|
onChange={(e) => setNewItem({ ...newItem, asn: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
{newInvalid.asn && <div className="invalid-feedback">Только цифры</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Community</label>
|
<label className="form-label">Community</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
||||||
placeholder="112"
|
placeholder="112"
|
||||||
value={newItem.community}
|
value={newItem.community}
|
||||||
onChange={(e) => setNewItem({ ...newItem, community: e.target.value })}
|
onChange={(e) => setNewItem({ ...newItem, community: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
{newInvalid.community && <div className="invalid-feedback">Только цифры</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">
|
||||||
@@ -256,7 +301,7 @@ function ASNsNewManager() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<IconDatabase className="icon me-2" />
|
<IconDeviceFloppy className="icon me-2" />
|
||||||
Сохранить в S3
|
Сохранить в S3
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -269,6 +314,28 @@ function ASNsNewManager() {
|
|||||||
<IconRefresh className="icon me-2" />
|
<IconRefresh className="icon me-2" />
|
||||||
Обновить
|
Обновить
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={handleImport}
|
||||||
|
>
|
||||||
|
<IconUpload className="icon me-2" />
|
||||||
|
Импорт из буфера
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
>
|
||||||
|
<IconDownload className="icon me-2" />
|
||||||
|
Экспорт CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={clearInvalid}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
>
|
||||||
|
Очистить пустые/невалидные
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +344,7 @@ function ASNsNewManager() {
|
|||||||
<div className="col-lg-9">
|
<div className="col-lg-9">
|
||||||
<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</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">
|
||||||
{/* Фильтр по community */}
|
{/* Фильтр по community */}
|
||||||
<select className="form-select w-auto" value={filterCommunity} onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}>
|
<select className="form-select w-auto" value={filterCommunity} onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}>
|
||||||
@@ -334,14 +401,14 @@ function ASNsNewManager() {
|
|||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control d-inline-block w-auto me-2"
|
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||||
value={editingValue}
|
value={editingValue}
|
||||||
ref={editInputRef}
|
ref={editInputRef}
|
||||||
onChange={e => setEditingValue(e.target.value)}
|
onChange={e => setEditingValue(e.target.value)}
|
||||||
onKeyDown={e => handleEditKeyDown(e, item.asn)}
|
onKeyDown={e => handleEditKeyDown(e, item.asn)}
|
||||||
style={{maxWidth: 120}}
|
style={{maxWidth: 120}}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(item.asn)}><IconCheck size={18} /></button>
|
<button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={18} /></button>
|
||||||
<button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
|
<button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ function BillingManager() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-vcenter">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
@@ -603,7 +603,11 @@ function BillingManager() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedData.map(item => (
|
{paginatedData.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="8" className="text-center text-muted py-4">Ничего не найдено. Измените фильтры или параметры поиска.</td>
|
||||||
|
</tr>
|
||||||
|
) : paginatedData.map(item => (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td>
|
<td>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
@@ -712,8 +716,8 @@ function BillingManager() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-33
@@ -16,9 +16,9 @@ import {
|
|||||||
IconCreditCard
|
IconCreditCard
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
function StatCard({ icon: Icon, color, value, title, subtitle }) {
|
function StatCard({ icon: Icon, color, value, title, subtitle, to }) {
|
||||||
return (
|
return (
|
||||||
<div className="card h-100">
|
<div className="card h-100 position-relative">
|
||||||
<div className="card-body d-flex align-items-center">
|
<div className="card-body d-flex align-items-center">
|
||||||
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
||||||
<Icon size={32} />
|
<Icon size={32} />
|
||||||
@@ -28,6 +28,9 @@ function StatCard({ icon: Icon, color, value, title, subtitle }) {
|
|||||||
<div className="text-muted lh-1">{subtitle}</div>
|
<div className="text-muted lh-1">{subtitle}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{to && (
|
||||||
|
<Link to={to} className="stretched-link" aria-label={title}></Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,6 +176,7 @@ function Dashboard() {
|
|||||||
value={loading ? '...' : stats.domainsCount ?? '—'}
|
value={loading ? '...' : stats.domainsCount ?? '—'}
|
||||||
title="Доменов"
|
title="Доменов"
|
||||||
subtitle="Всего доменов в системе"
|
subtitle="Всего доменов в системе"
|
||||||
|
to="/domains"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
<div className="col-md-3">
|
||||||
@@ -182,6 +186,7 @@ function Dashboard() {
|
|||||||
value={loading ? '...' : stats.ipRangesCount ?? '—'}
|
value={loading ? '...' : stats.ipRangesCount ?? '—'}
|
||||||
title="IP-диапазонов"
|
title="IP-диапазонов"
|
||||||
subtitle="Всего IP диапазонов"
|
subtitle="Всего IP диапазонов"
|
||||||
|
to="/ip-ranges"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
<div className="col-md-3">
|
||||||
@@ -191,6 +196,7 @@ function Dashboard() {
|
|||||||
value={loading ? '...' : stats.asnsCount ?? '—'}
|
value={loading ? '...' : stats.asnsCount ?? '—'}
|
||||||
title="AS"
|
title="AS"
|
||||||
subtitle="Всего Autonomous Systems"
|
subtitle="Всего Autonomous Systems"
|
||||||
|
to="/asns"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-3">
|
<div className="col-md-3">
|
||||||
@@ -200,6 +206,7 @@ function Dashboard() {
|
|||||||
value={loading ? '...' : stats.serversCount ?? '—'}
|
value={loading ? '...' : stats.serversCount ?? '—'}
|
||||||
title="Серверов"
|
title="Серверов"
|
||||||
subtitle="Всего серверов"
|
subtitle="Всего серверов"
|
||||||
|
to="/servers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,37 +251,7 @@ function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Быстрые действия */}
|
{/* Убрали быстрые действия, карточки выше кликабельны */}
|
||||||
<div className="card">
|
|
||||||
<div className="card-header">
|
|
||||||
<h3 className="card-title">Быстрые действия</h3>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="btn-list">
|
|
||||||
<Link to="/domains" className="btn btn-outline-primary">
|
|
||||||
<IconWorld className="me-2" /> Домены
|
|
||||||
</Link>
|
|
||||||
<Link to="/ip-ranges" className="btn btn-outline-primary">
|
|
||||||
<IconNetwork className="me-2" /> IP-диапазоны
|
|
||||||
</Link>
|
|
||||||
<Link to="/asns" className="btn btn-outline-primary">
|
|
||||||
<IconNetwork className="me-2" /> AS
|
|
||||||
</Link>
|
|
||||||
<Link to="/servers" className="btn btn-outline-primary">
|
|
||||||
<IconServer className="me-2" /> Серверы
|
|
||||||
</Link>
|
|
||||||
<Link to="/filters" className="btn btn-outline-primary">
|
|
||||||
<IconFilter className="me-2" /> Фильтры
|
|
||||||
</Link>
|
|
||||||
<Link to="/auto-urls" className="btn btn-outline-primary">
|
|
||||||
<IconDownload className="me-2" /> Авто URL
|
|
||||||
</Link>
|
|
||||||
<Link to="/billing" className="btn btn-outline-primary">
|
|
||||||
<IconCreditCard className="me-2" /> Биллинг
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ function ServerManager() {
|
|||||||
{/* Таблица серверов на всю ширину */}
|
{/* Таблица серверов на всю ширину */}
|
||||||
<div className="card w-100">
|
<div className="card w-100">
|
||||||
<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">Список серверов</h3>
|
<h3 className="card-title mb-0">Список серверов <span className="badge bg-blue-lt text-blue ms-2">{filteredServers.length}</span></h3>
|
||||||
<div className="d-flex gap-2 w-75">
|
<div className="d-flex gap-2 w-75">
|
||||||
{/* Фильтры и поиск */}
|
{/* Фильтры и поиск */}
|
||||||
<select className="form-select w-auto" value={filterTunnel} onChange={e => { setFilterTunnel(e.target.value); setCurrentPage(1); }}>
|
<select className="form-select w-auto" value={filterTunnel} onChange={e => { setFilterTunnel(e.target.value); setCurrentPage(1); }}>
|
||||||
@@ -512,6 +512,14 @@ function ServerManager() {
|
|||||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setFilterTunnel(''); setFilterCountry(''); setFilterProvider(''); setSearchTerm(''); setCurrentPage(1); }}
|
||||||
|
title="Сбросить фильтры"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -571,7 +579,11 @@ function ServerManager() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedServers.map((server) => (
|
{paginatedServers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="text-center text-muted py-4">Ничего не найдено. Измените фильтры или поиск.</td>
|
||||||
|
</tr>
|
||||||
|
) : paginatedServers.map((server) => (
|
||||||
<tr key={server.ip} className={editingServer === server.ip ? 'table-info' : ''}>
|
<tr key={server.ip} className={editingServer === server.ip ? 'table-info' : ''}>
|
||||||
<td>{server.ip}</td>
|
<td>{server.ip}</td>
|
||||||
<td>{server.dns}</td>
|
<td>{server.dns}</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user