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
+157 -89
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
<button type="button" className="btn-close" onClick={() => setSuccess('')}></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,22 +585,6 @@ function ASNsNewManager() {
loading={loading} loading={loading}
/> />
<div className="row">
<div className="col-12">
{/* BulkActionsBar */}
<BulkActionsBar
selectedCount={selectedItems.size}
totalCount={paginatedItems.length}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
onDelete={selectedItems.size > 0 ? () => {
if (confirm(`Удалить ${selectedItems.size} записей?`)) {
handleBulkDelete();
}
} : null}
onExport={selectedItems.size > 0 ? handleBulkExport : null}
/>
<div className="card card-flat"> <div className="card card-flat">
<div className="card-header"> <div className="card-header">
<div className="row align-items-center w-100 g-2"> <div className="row align-items-center w-100 g-2">
@@ -596,7 +593,9 @@ function ASNsNewManager() {
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<IconHash className="icon text-muted" size={18} /> <IconHash className="icon text-muted" size={18} />
<span className="text-muted text-uppercase small">Список</span> <span className="text-muted text-uppercase small">Список</span>
<span className="badge bg-blue-lt text-blue">{filtered.length}</span> <span className="badge bg-blue-lt text-blue">
{filtered.length}
</span>
</div> </div>
<div className="d-none d-md-block text-muted small"> <div className="d-none d-md-block text-muted small">
Всего записей: {filtered.length} Всего записей: {filtered.length}
@@ -605,49 +604,30 @@ function ASNsNewManager() {
</div> </div>
<div className="col-12 mt-2"></div> <div className="col-12 mt-2"></div>
<div className="col d-flex gap-2 flex-wrap align-items-center"> <div className="col d-flex gap-2 flex-wrap align-items-center">
<SavedFilters <div className="input-icon" style={{ minWidth: '200px' }}>
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"> <span className="input-icon-addon">
<IconFilter size={18} /> <IconFilter size={18} />
</span> </span>
<select <select
className="form-select" className="form-select"
style={{ minWidth: '200px' }}
value={filterCommunity} value={filterCommunity}
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }} onChange={(e) => {
title={(() => { setFilterCommunity(e.target.value);
const meta = communities.find(c => String(c.value) === String(filterCommunity)); setCurrentPage(1);
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> <option value="">Все community</option>
{allCommunities.map(community => { {allCommunities.map((community) => (
const meta = communities.find(c => String(c.value) === String(community)); <option key={community} value={String(community)}>
const label = meta && meta.name ? `${community} — ${meta.name}` : community; {community}
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community; </option>
return ( ))}
<option key={community} value={String(community)} title={title}>{label}</option>
);
})}
</select> </select>
</div> </div>
{/* Поиск */} <div
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}> 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>
@@ -656,7 +636,10 @@ function ASNsNewManager() {
className="form-control" className="form-control"
placeholder="Поиск ASN..." placeholder="Поиск ASN..."
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 }} style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
/> />
@@ -664,9 +647,18 @@ function ASNsNewManager() {
<button <button
type="button" type="button"
className="btn btn-ghost-secondary btn-icon btn-sm" className="btn btn-ghost-secondary btn-icon btn-sm"
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }} style={{
position: 'absolute',
right: '0.25rem',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2,
}}
aria-label="Очистить поиск" aria-label="Очистить поиск"
onClick={() => { setSearchTerm(''); setCurrentPage(1); }} onClick={() => {
setSearchTerm('');
setCurrentPage(1);
}}
> >
<IconX size={16} /> <IconX size={16} />
</button> </button>
@@ -682,8 +674,9 @@ function ASNsNewManager() {
</button> </button>
</div> </div>
</div> </div>
</div>
<div className="card-body p-0"> <div className="card-body p-0">
{/* Таблица на лёгкой карточной подложке */}
{loading ? ( {loading ? (
<TableSkeleton rows={10} cols={3} flat /> <TableSkeleton rows={10} cols={3} flat />
) : paginatedItems.length === 0 ? ( ) : paginatedItems.length === 0 ? (
@@ -691,14 +684,39 @@ function ASNsNewManager() {
<EmptyState <EmptyState
title="Нет ASN" title="Нет ASN"
description="Импортируйте или добавьте записи, чтобы начать." description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} action={
secondaryAction={<button className="btn btn-outline-primary" onClick={() => { <button className="btn btn-primary" onClick={handleImport}>
Импорт
</button>
}
secondaryAction={
<button
className="btn btn-outline-primary"
onClick={() => {
const header = ['asn', 'community']; const header = ['asn', 'community'];
const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n'); const lines = [header, ['12345', '65000:100']]
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' }); .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 url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url); const a = document.createElement('a');
}}>Шаблон CSV</button>} a.href = url;
a.download = 'asns_sample.csv';
a.click();
URL.revokeObjectURL(url);
}}
>
Шаблон CSV
</button>
}
/> />
</TableEmpty> </TableEmpty>
) : ( ) : (
@@ -710,7 +728,10 @@ function ASNsNewManager() {
<input <input
type="checkbox" type="checkbox"
className="form-check-input" className="form-check-input"
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.asn))} checked={
paginatedItems.length > 0 &&
paginatedItems.every((i) => selectedItems.has(i.asn))
}
onChange={(e) => { onChange={(e) => {
if (e.target.checked) { if (e.target.checked) {
selectAll(); selectAll();
@@ -721,27 +742,39 @@ function ASNsNewManager() {
title="Выбрать все на странице" title="Выбрать все на странице"
/> />
</th> </th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}> <th
className="cursor-pointer user-select-none"
onClick={() => handleSort('asn')}
>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" /> <IconHash size={16} className="me-1 text-muted" />
Номер AS Номер AS
{sortField === 'asn' && ( {sortField === 'asn' && (
<span className="ms-1"> <span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />} {sortOrder === 'asc' ? (
<IconArrowUp size={14} />
) : (
<IconArrowDown size={14} />
)}
</span> </span>
)} )}
</div> </div>
</th> </th>
<th> <th>Имя AS</th>
Имя AS <th
</th> className="cursor-pointer user-select-none"
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}> onClick={() => handleSort('community')}
>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" /> <IconHash size={16} className="me-1 text-muted" />
Community Community
{sortField === 'community' && ( {sortField === 'community' && (
<span className="ms-1"> <span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />} {sortOrder === 'asc' ? (
<IconArrowUp size={14} />
) : (
<IconArrowDown size={14} />
)}
</span> </span>
)} )}
</div> </div>
@@ -751,7 +784,15 @@ function ASNsNewManager() {
</thead> </thead>
<tbody> <tbody>
{paginatedItems.map((item, idx) => ( {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` }}> <tr
key={item.asn}
className={
selectedItems.has(item.asn) ? 'table-selected' : ''
}
style={{
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`,
}}
>
<td> <td>
<input <input
type="checkbox" type="checkbox"
@@ -786,14 +827,28 @@ function ASNsNewManager() {
</td> </td>
<td>{renderCommunityBadge(item.community)}</td> <td>{renderCommunityBadge(item.community)}</td>
<td className="text-end"> <td className="text-end">
<div className="btn-group btn-group-sm" role="group" aria-label={`Действия с ASN ${item.asn}`}> <div
className="btn-group btn-group-sm"
role="group"
aria-label={`Действия с ASN ${item.asn}`}
>
<Tooltip content="Редактировать" position="top"> <Tooltip content="Редактировать" position="top">
<button className="btn btn-icon btn-outline-primary me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"> <button
className="btn btn-icon btn-outline-primary me-1"
aria-label="Редактировать"
onClick={() => handleEdit(item)}
title="Редактировать"
>
<IconEdit size={16} /> <IconEdit size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip content="Удалить" position="top"> <Tooltip content="Удалить" position="top">
<button className="btn btn-icon btn-outline-danger" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"> <button
className="btn btn-icon btn-outline-danger"
aria-label="Удалить"
onClick={() => confirmDelete(item)}
title="Удалить"
>
<IconTrash size={16} /> <IconTrash size={16} />
</button> </button>
</Tooltip> </Tooltip>
@@ -806,6 +861,7 @@ function ASNsNewManager() {
</div> </div>
)} )}
</div> </div>
<div className="card-footer border-top-0"> <div className="card-footer border-top-0">
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
@@ -816,13 +872,15 @@ function ASNsNewManager() {
/> />
</div> </div>
</div> </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'}
@@ -847,7 +907,7 @@ function ASNsNewManager() {
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>
); );
} }