feat: Refactor ServerManager layout to improve server list display and enhance filtering options
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 18m28s

This commit is contained in:
2025-07-11 08:46:03 +07:00
parent 9e4c6863b6
commit 85ed473cca
+150 -189
View File
@@ -433,200 +433,161 @@ function ServerManager() {
</button>
</div>
<div className="row">
<div className="col-lg-4">
{/* Add New Server Card */}
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
<IconPlus className="icon me-2" />
Управление серверами
</h3>
</div>
<div className="card-body">
<div className="form-footer">
{/* The button for adding new server is now outside this column */}
</div>
</div>
</div>
{/* Save Changes Card */}
<div className="card card-md mt-3">
<div className="card-header">
<h3 className="card-title">
<IconDatabase className="icon me-2" />
Сохранение изменений
</h3>
</div>
<div className="card-body">
<p className="text-muted mb-3">
Все изменения сохраняются в файл servers.json в S3 хранилище.
</p>
<div className="form-footer">
{/* The save changes buttons are now outside this column */}
</div>
{/* Таблица серверов на всю ширину */}
<div className="card w-100">
<div className="card-header d-flex justify-content-between align-items-center">
<h3 className="card-title mb-0">Список серверов</h3>
<div className="d-flex gap-2 w-75">
{/* Фильтры и поиск */}
<select className="form-select w-auto" value={filterTunnel} onChange={e => { setFilterTunnel(e.target.value); setCurrentPage(1); }}>
<option value="">Все туннели</option>
{allTunnels.map(tunnel => (
<option key={tunnel} value={tunnel}>{tunnel}</option>
))}
</select>
<select className="form-select w-auto" value={filterCountry} onChange={e => { setFilterCountry(e.target.value); setCurrentPage(1); }}>
<option value="">Все страны</option>
{allCountries.map(country => (
<option key={country} value={country}>{country}</option>
))}
</select>
<select className="form-select w-auto" value={filterProvider} onChange={e => { setFilterProvider(e.target.value); setCurrentPage(1); }}>
<option value="">Все провайдеры</option>
{allProviders.map(provider => (
<option key={provider} value={provider}>{provider}</option>
))}
</select>
<div className="input-icon flex-grow-1">
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск серверов..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
</div>
</div>
</div>
<div className="col-lg-8">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<h3 className="card-title mb-0">Список серверов</h3>
<div className="d-flex gap-2 w-75">
{/* Фильтры */}
<select className="form-select w-auto" value={filterTunnel} onChange={e => { setFilterTunnel(e.target.value); setCurrentPage(1); }}>
<option value="">Все туннели</option>
{allTunnels.map(tunnel => (
<option key={tunnel} value={tunnel}>{tunnel}</option>
))}
</select>
<select className="form-select w-auto" value={filterCountry} onChange={e => { setFilterCountry(e.target.value); setCurrentPage(1); }}>
<option value="">Все страны</option>
{allCountries.map(country => (
<option key={country} value={country}>{country}</option>
))}
</select>
<select className="form-select w-auto" value={filterProvider} onChange={e => { setFilterProvider(e.target.value); setCurrentPage(1); }}>
<option value="">Все провайдеры</option>
{allProviders.map(provider => (
<option key={provider} value={provider}>{provider}</option>
))}
</select>
{/* Поиск */}
<div className="input-icon flex-grow-1">
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск серверов..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
</div>
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={() => handleSort('ip')}>
IP адрес
{sortField === 'ip' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('dns')}>
DNS имя
{sortField === 'dns' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('country')}>
Страна
{sortField === 'country' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('provider')}>
Провайдер
{sortField === 'provider' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('tunnel')}>
Туннель
{sortField === 'tunnel' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('gateway')}>
Основной шлюз
{sortField === 'gateway' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th className="text-center">Статус</th>
<th className="text-end">Ссылка</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{paginatedServers.map((server) => (
<tr key={server.ip} className={editingServer === server.ip ? 'table-info' : ''}>
<td>{server.ip}</td>
<td>{server.dns}</td>
<td><span className="badge bg-blue-lt text-blue">{countryToFlag(server.country)} {server.country}</span></td>
<td>{server.provider}</td>
<td><span className="badge bg-green-lt text-green">{server.tunnel}</span></td>
<td className="text-center">
<span className="badge bg-blue-lt text-blue">
{server.gateway}
</span>
</td>
<td className="text-center">
<span className="badge bg-success-lt text-success">Онлайн</span>
</td>
<td className="text-end">
<button
className="btn btn-outline-info btn-icon"
onClick={() => handleGenerateLink(server)}
onContextMenu={(e) => {
e.preventDefault();
quickCopyLink(server);
}}
title="Левый клик - настройки, правый клик - быстрое копирование"
>
<IconLink size={18} />
</button>
</td>
<td className="text-end">
<button className="btn btn-outline-primary btn-icon me-1" onClick={() => handleEdit(server)}><IconEdit size={18} /></button>
<button className="btn btn-outline-danger btn-icon" onClick={() => confirmDelete(server)}><IconTrash size={18} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Пагинация */}
{totalPages > 1 && (
<div className="card-footer d-flex align-items-center">
<p className="m-0 text-muted">
Показано <span>{(currentPage - 1) * pageSize + 1}</span> до <span>{Math.min(currentPage * pageSize, filteredServers.length)}</span> из <span>{filteredServers.length}</span> серверов
</p>
<ul className="pagination m-0 ms-auto">
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>
<IconChevronDown className="icon icon-sm rotate-180" />
Назад
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={() => handleSort('ip')}>
IP адрес
{sortField === 'ip' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('dns')}>
DNS имя
{sortField === 'dns' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('country')}>
Страна
{sortField === 'country' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('provider')}>
Провайдер
{sortField === 'provider' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('tunnel')}>
Туннель
{sortField === 'tunnel' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('gateway')}>
Основной шлюз
{sortField === 'gateway' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th className="text-center">Статус</th>
<th className="text-end">Ссылка</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{paginatedServers.map((server) => (
<tr key={server.ip} className={editingServer === server.ip ? 'table-info' : ''}>
<td>{server.ip}</td>
<td>{server.dns}</td>
<td><span className="badge bg-blue-lt text-blue">{countryToFlag(server.country)} {server.country}</span></td>
<td>{server.provider}</td>
<td><span className="badge bg-green-lt text-green">{server.tunnel}</span></td>
<td className="text-center">
<span className="badge bg-blue-lt text-blue">
{server.gateway}
</span>
</td>
<td className="text-center">
<span className="badge bg-success-lt text-success">Онлайн</span>
</td>
<td className="text-end">
<button
className="btn btn-outline-info btn-icon"
onClick={() => handleGenerateLink(server)}
onContextMenu={(e) => {
e.preventDefault();
quickCopyLink(server);
}}
title="Левый клик - настройки, правый клик - быстрое копирование"
>
<IconLink size={18} />
</button>
</li>
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>
Вперед
<IconChevronDown className="icon icon-sm" />
</button>
</li>
</ul>
</div>
)}
</div>
</td>
<td className="text-end">
<button className="btn btn-outline-primary btn-icon me-1" onClick={() => handleEdit(server)}><IconEdit size={18} /></button>
<button className="btn btn-outline-danger btn-icon" onClick={() => confirmDelete(server)}><IconTrash size={18} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Пагинация */}
{totalPages > 1 && (
<div className="card-footer d-flex align-items-center">
<p className="m-0 text-muted">
Показано <span>{(currentPage - 1) * pageSize + 1}</span> до <span>{Math.min(currentPage * pageSize, filteredServers.length)}</span> из <span>{filteredServers.length}</span> серверов
</p>
<ul className="pagination m-0 ms-auto">
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>
<IconChevronDown className="icon icon-sm rotate-180" />
Назад
</button>
</li>
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>
Вперед
<IconChevronDown className="icon icon-sm" />
</button>
</li>
</ul>
</div>
)}
</div>
{/* Delete Modal */}