refactor(ASNsNewManager): enhance JSX formatting and structure for improved readability and consistency in component layout
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s

This commit is contained in:
2026-02-16 19:02:37 +07:00
parent 4ce3bf4a9f
commit 700e6674e5
+321 -253
View File
@@ -517,18 +517,20 @@ function ASNsNewManager() {
<div> <div>
<IconCheck className="icon alert-icon" /> <IconCheck className="icon alert-icon" />
</div> </div>
<div> <div>{success}</div>
{success}
</div>
</div> </div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button> <button
type="button"
className="btn-close"
onClick={() => setSuccess('')}
></button>
</div> </div>
)} )}
<PageHeader <PageHeader
title="ASNs" title="ASNs"
meta={<LastSaved timestamp={lastModified} variant="compact" />} meta={<LastSaved timestamp={lastModified} variant="compact" />}
actions={( actions={
<PageHeaderActions <PageHeaderActions
loading={loading} loading={loading}
onPreview={handlePreviewDiff} onPreview={handlePreviewDiff}
@@ -540,7 +542,13 @@ function ASNsNewManager() {
disableExport={items.length === 0} disableExport={items.length === 0}
onClear={clearInvalid} onClear={clearInvalid}
disableClear={items.length === 0} disableClear={items.length === 0}
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }} onClearCommunities={() => {
if (!filterCommunity) {
setError('Выберите community в фильтре для очистки');
return;
}
setClearCommunitiesOpen(true);
}}
disableClearCommunities={items.length === 0 || !filterCommunity} disableClearCommunities={items.length === 0 || !filterCommunity}
onSave={handleSaveChanges} onSave={handleSaveChanges}
disableSave={loading} disableSave={loading}
@@ -549,18 +557,23 @@ function ASNsNewManager() {
onOnlineUpdate={() => setWsOpen(true)} onOnlineUpdate={() => setWsOpen(true)}
onBackgroundUpdate={async () => { onBackgroundUpdate={async () => {
try { try {
const res = await fetch('/api/update-bgp/background', { method: 'POST' }); const res = await fetch('/api/update-bgp/background', {
method: 'POST',
});
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) { if (!res.ok || data.ok === false) {
throw new Error(data?.message || `HTTP ${res.status}`); throw new Error(data?.message || `HTTP ${res.status}`);
} }
notifyMutationSuccess('Фоновое обновление запущено', data); notifyMutationSuccess('Фоновое обновление запущено', data);
} catch (e) { } catch (e) {
window.notify?.error('Не удалось запустить фоновое обновление', String(e)); window.notify?.error(
'Не удалось запустить фоновое обновление',
String(e),
);
} }
}} }}
/> />
)} }
/> />
<AddItemModal <AddItemModal
@@ -572,257 +585,302 @@ function ASNsNewManager() {
loading={loading} loading={loading}
/> />
<div className="row"> <div className="card card-flat">
<div className="col-12"> <div className="card-header">
{/* BulkActionsBar */} <div className="row align-items-center w-100 g-2">
<BulkActionsBar <div className="col">
selectedCount={selectedItems.size} <div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
totalCount={paginatedItems.length} <div className="d-flex align-items-center gap-2">
onSelectAll={selectAll} <IconHash className="icon text-muted" size={18} />
onDeselectAll={deselectAll} <span className="text-muted text-uppercase small">Список</span>
onDelete={selectedItems.size > 0 ? () => { <span className="badge bg-blue-lt text-blue">
if (confirm(`Удалить ${selectedItems.size} записей?`)) { {filtered.length}
handleBulkDelete();
}
} : null}
onExport={selectedItems.size > 0 ? handleBulkExport : null}
/>
<div className="card card-flat">
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="d-flex align-items-center gap-2">
<IconHash className="icon text-muted" size={18} />
<span className="text-muted text-uppercase small">Список</span>
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
</div>
<div className="d-none d-md-block text-muted small">
Всего записей: {filtered.length}
</div>
</div>
</div>
<div className="col-12 mt-2"></div>
<div className="col d-flex gap-2 flex-wrap align-items-center">
<SavedFilters
pageKey="asns"
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
onApplyFilter={(filters) => {
setSearchTerm(filters.searchTerm || '');
setFilterCommunity(filters.filterCommunity || '');
setSortField(filters.sortField || 'asn');
setSortOrder(filters.sortOrder || 'asc');
setCurrentPage(1);
}}
/>
{/* Фильтр по community */}
<div className="input-icon">
<span className="input-icon-addon">
<IconFilter size={18} />
</span> </span>
<select
className="form-select"
style={{ minWidth: '200px' }}
value={filterCommunity}
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
title={(() => {
const meta = communities.find(c => String(c.value) === String(filterCommunity));
if (!meta) return 'Все community';
const name = meta.name ? meta.name + ' — ' : '';
const desc = meta.description || '';
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
return name + desc + tags;
})()}
>
<option value="">Все community</option>
{allCommunities.map(community => {
const meta = communities.find(c => String(c.value) === String(community));
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 key={community} value={String(community)} title={title}>{label}</option>
);
})}
</select>
</div> </div>
{/* Поиск */} <div className="d-none d-md-block text-muted small">
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}> Всего записей: {filtered.length}
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск ASN..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
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>
<button
className="btn btn-primary"
onClick={() => setAddModalOpen(true)}
title="Добавить новый ASN"
>
<IconPlus className="me-1" size={18} />
Добавить
</button>
</div> </div>
</div> </div>
<div className="card-body p-0"> <div className="col-12 mt-2"></div>
{/* Таблица на лёгкой карточной подложке */} <div className="col d-flex gap-2 flex-wrap align-items-center">
{loading ? ( <div className="input-icon" style={{ minWidth: '200px' }}>
<TableSkeleton rows={10} cols={3} flat /> <span className="input-icon-addon">
) : paginatedItems.length === 0 ? ( <IconFilter size={18} />
<TableEmpty cols={3} flat> </span>
<EmptyState <select
title="Нет ASN" className="form-select"
description="Импортируйте или добавьте записи, чтобы начать." value={filterCommunity}
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} onChange={(e) => {
secondaryAction={<button className="btn btn-outline-primary" onClick={() => { setFilterCommunity(e.target.value);
const header = ['asn','community']; setCurrentPage(1);
const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n'); }}
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' }); >
const url = URL.createObjectURL(blob); <option value="">Все community</option>
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url); {allCommunities.map((community) => (
}}>Шаблон CSV</button>} <option key={community} value={String(community)}>
/> {community}
</TableEmpty> </option>
) : ( ))}
<div className="table-responsive"> </select>
<table className="table table-flat table-vcenter table-nowrap mb-0"> </div>
<thead> <div
<tr> className="input-icon flex-grow-1"
<th style={{width: '40px'}}> style={{ minWidth: '200px', position: 'relative' }}
<input >
type="checkbox" <span className="input-icon-addon">
className="form-check-input" <IconSearch size={18} />
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.asn))} </span>
onChange={(e) => { <input
if (e.target.checked) { type="text"
selectAll(); className="form-control"
} else { placeholder="Поиск ASN..."
deselectAll(); value={searchTerm}
} onChange={(e) => {
}} setSearchTerm(e.target.value);
title="Выбрать все на странице" setCurrentPage(1);
/> }}
</th> aria-label="Поиск ASN"
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}> style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
<div className="d-flex align-items-center"> />
<IconHash size={16} className="me-1 text-muted" /> {!!searchTerm && (
Номер AS <button
{sortField === 'asn' && ( type="button"
<span className="ms-1"> className="btn btn-ghost-secondary btn-icon btn-sm"
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />} style={{
</span> position: 'absolute',
)} right: '0.25rem',
</div> top: '50%',
</th> transform: 'translateY(-50%)',
<th> zIndex: 2,
Имя AS }}
</th> aria-label="Очистить поиск"
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}> onClick={() => {
<div className="d-flex align-items-center"> setSearchTerm('');
<IconHash size={16} className="me-1 text-muted" /> setCurrentPage(1);
Community }}
{sortField === 'community' && ( >
<span className="ms-1"> <IconX size={16} />
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />} </button>
</span> )}
)} </div>
</div> <button
</th> className="btn btn-primary"
<th className="text-end">Действия</th> onClick={() => setAddModalOpen(true)}
</tr> title="Добавить новый ASN"
</thead> >
<tbody> <IconPlus className="me-1" size={18} />
{paginatedItems.map((item, idx) => ( Добавить
<tr key={item.asn} className={selectedItems.has(item.asn) ? 'table-selected' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}> </button>
<td>
<input
type="checkbox"
className="form-check-input"
checked={selectedItems.has(item.asn)}
onChange={() => toggleSelectItem(item.asn)}
onClick={(e) => e.stopPropagation()}
/>
</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>
<Tooltip content="Копировать ASN" position="top">
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.asn)}
aria-label="Копировать"
title="Копировать ASN"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td title={asnNameMap[item.asn] || ''}>
{asnNameMap[item.asn] ? (
<span>{asnNameMap[item.asn]}</span>
) : (
<span className="text-muted">—</span>
)}
</td>
<td>{renderCommunityBadge(item.community)}</td>
<td className="text-end">
<div className="btn-group btn-group-sm" role="group" aria-label={`Действия с ASN ${item.asn}`}>
<Tooltip content="Редактировать" position="top">
<button className="btn btn-icon btn-outline-primary me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать">
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button className="btn btn-icon btn-outline-danger" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить">
<IconTrash size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="card-footer border-top-0">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
</div> </div>
</div> </div>
</div> </div>
<div className="card-body p-0">
{loading ? (
<TableSkeleton rows={10} cols={3} flat />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3} flat>
<EmptyState
title="Нет ASN"
description="Импортируйте или добавьте записи, чтобы начать."
action={
<button className="btn btn-primary" onClick={handleImport}>
Импорт
</button>
}
secondaryAction={
<button
className="btn btn-outline-primary"
onClick={() => {
const header = ['asn', 'community'];
const lines = [header, ['12345', '65000:100']]
.map((r) =>
r
.map((x) =>
`"${(x ?? '').toString().replace(/"/g, '""')}"`,
)
.join(','),
)
.join('\n');
const blob = new Blob([lines], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'asns_sample.csv';
a.click();
URL.revokeObjectURL(url);
}}
>
Шаблон CSV
</button>
}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={
paginatedItems.length > 0 &&
paginatedItems.every((i) => selectedItems.has(i.asn))
}
onChange={(e) => {
if (e.target.checked) {
selectAll();
} else {
deselectAll();
}
}}
title="Выбрать все на странице"
/>
</th>
<th
className="cursor-pointer user-select-none"
onClick={() => handleSort('asn')}
>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Номер AS
{sortField === 'asn' && (
<span className="ms-1">
{sortOrder === 'asc' ? (
<IconArrowUp size={14} />
) : (
<IconArrowDown size={14} />
)}
</span>
)}
</div>
</th>
<th>Имя AS</th>
<th
className="cursor-pointer user-select-none"
onClick={() => handleSort('community')}
>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? (
<IconArrowUp size={14} />
) : (
<IconArrowDown size={14} />
)}
</span>
)}
</div>
</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{paginatedItems.map((item, idx) => (
<tr
key={item.asn}
className={
selectedItems.has(item.asn) ? 'table-selected' : ''
}
style={{
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`,
}}
>
<td>
<input
type="checkbox"
className="form-check-input"
checked={selectedItems.has(item.asn)}
onChange={() => toggleSelectItem(item.asn)}
onClick={(e) => e.stopPropagation()}
/>
</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>
<Tooltip content="Копировать ASN" position="top">
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.asn)}
aria-label="Копировать"
title="Копировать ASN"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td title={asnNameMap[item.asn] || ''}>
{asnNameMap[item.asn] ? (
<span>{asnNameMap[item.asn]}</span>
) : (
<span className="text-muted">—</span>
)}
</td>
<td>{renderCommunityBadge(item.community)}</td>
<td className="text-end">
<div
className="btn-group btn-group-sm"
role="group"
aria-label={`Действия с ASN ${item.asn}`}
>
<Tooltip content="Редактировать" position="top">
<button
className="btn btn-icon btn-outline-primary me-1"
aria-label="Редактировать"
onClick={() => handleEdit(item)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button
className="btn btn-icon btn-outline-danger"
aria-label="Удалить"
onClick={() => confirmDelete(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="card-footer border-top-0">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
</div>
</div> </div>
<ConfirmDialog <ConfirmDialog
open={showDeleteModal} open={showDeleteModal}
title={'Удалить ASN?'} title={'Удалить ASN?'}
message={!itemToDelete ? '' : `Вы уверены, что хотите удалить "${itemToDelete.asn}"? Это действие необратимо.`} message={
!itemToDelete
? ''
: `Вы уверены, что хотите удалить "${itemToDelete.asn}"? Это действие необратимо.`
}
confirmText={'Удалить'} confirmText={'Удалить'}
cancelText={'Отмена'} cancelText={'Отмена'}
destructive destructive
@@ -830,12 +888,14 @@ function ASNsNewManager() {
onConfirm={executeDelete} onConfirm={executeDelete}
onCancel={() => setShowDeleteModal(false)} onCancel={() => setShowDeleteModal(false)}
/> />
<ConfirmDiffModal <ConfirmDiffModal
show={confirmSaveOpen} show={confirmSaveOpen}
diff={diff} diff={diff}
onConfirm={performSave} onConfirm={performSave}
onClose={() => setConfirmSaveOpen(false)} onClose={() => setConfirmSaveOpen(false)}
/> />
<ImportModal <ImportModal
show={importOpen} show={importOpen}
title={'Импорт ASN'} title={'Импорт ASN'}
@@ -844,10 +904,10 @@ function ASNsNewManager() {
validateItem={validateItem} validateItem={validateItem}
onConfirm={applyImportedItems} onConfirm={applyImportedItems}
onClose={() => setImportOpen(false)} onClose={() => setImportOpen(false)}
sampleHeader={['asn','community']} sampleHeader={['asn', 'community']}
placeholder={'12345 65000:100'} placeholder={'12345 65000:100'}
/> />
{/* Модальное окно редактирования community */}
<FormModal <FormModal
show={editModalOpen && !!itemBeingEdited} show={editModalOpen && !!itemBeingEdited}
onClose={handleCancelEdit} onClose={handleCancelEdit}
@@ -862,7 +922,8 @@ function ASNsNewManager() {
{itemBeingEdited && ( {itemBeingEdited && (
<> <>
<p className="text-muted mb-2"> <p className="text-muted mb-2">
ASN: <span className="font-monospace">{itemBeingEdited.asn}</span> ASN:{' '}
<span className="font-monospace">{itemBeingEdited.asn}</span>
</p> </p>
<div className="mb-3"> <div className="mb-3">
<label className="form-label">Community</label> <label className="form-label">Community</label>
@@ -871,7 +932,9 @@ function ASNsNewManager() {
onChange={setEditingValue} onChange={setEditingValue}
communities={communities} communities={communities}
onKeyDown={handleEditKeyDown} onKeyDown={handleEditKeyDown}
className={`form-control${isValidCommunity(editingValue) ? '' : ' is-invalid'}`} className={`form-control${
isValidCommunity(editingValue) ? '' : ' is-invalid'
}`}
useShortValue={true} useShortValue={true}
/> />
{!isValidCommunity(editingValue) && ( {!isValidCommunity(editingValue) && (
@@ -883,27 +946,32 @@ function ASNsNewManager() {
</> </>
)} )}
</FormModal> </FormModal>
<HistoryModal <HistoryModal
resource="asns" resource="asns"
show={historyOpen} show={historyOpen}
onClose={() => setHistoryOpen(false)} onClose={() => setHistoryOpen(false)}
onRolledBack={() => fetchItems()} onRolledBack={() => fetchItems()}
/> />
<WsUpdateModal <WsUpdateModal
show={wsOpen} show={wsOpen}
url={wsUrl} url={wsUrl}
onClose={() => setWsOpen(false)} onClose={() => setWsOpen(false)}
/> />
<ConfirmDialog <ConfirmDialog
open={clearCommunitiesOpen} open={clearCommunitiesOpen}
title={'Удалить записи community'} title={'Удалить записи community'}
message={`Будут удалены все записи с community "${filterCommunity}". Действие НЕ сохраняет изменения автоматически.`} message={`Будут удалены все записи с community "${filterCommunity}". Действие НЕ сохраняет изменения автоматически.`}
confirmText={'Удалить'} confirmText={'Удалить'}
cancelText={'Отмена'} cancelText={'Отмена'}
onConfirm={() => { setItems(prev => prev.filter(i => i.community !== filterCommunity)); setClearCommunitiesOpen(false); }} onConfirm={() => {
setItems((prev) => prev.filter((i) => i.community !== filterCommunity));
setClearCommunitiesOpen(false);
}}
onCancel={() => setClearCommunitiesOpen(false)} onCancel={() => setClearCommunitiesOpen(false)}
/> />
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
</div> </div>
); );
} }