Refactor server picker and limit ping requests
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-12 13:42:22 +03:00
parent 24fda3e34e
commit 57330f1c78
10 changed files with 612 additions and 83 deletions

View File

@@ -16,6 +16,7 @@ import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
import { operationBlocked } from '../state/operations.js';
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
import { ServerPicker } from './ServerPicker.jsx';
import { canAppendRouteRule } from '../../shared/routingRules.js';
import {
HARBOR_VERSIONS,
@@ -623,7 +624,6 @@ export function ClientOverviewPage({
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [showIntro, setShowIntro] = useState(!hasSubscription);
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
const [pings, setPings] = useState({});
const [accessTab, setAccessTab] = useState('gateway');
const [copyFeedback, setCopyFeedback] = useState(null);
const [refreshingInfo, setRefreshingInfo] = useState(false);
@@ -648,7 +648,6 @@ export function ClientOverviewPage({
const localRulesCloseRef = useRef(null);
const localRulesBaselineRef = useRef('[]');
const previousHasSubscriptionRef = useRef(hasSubscription);
const serverKey = servers.map((server) => server.id).join('|');
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
@@ -695,31 +694,6 @@ export function ClientOverviewPage({
return () => clearInterval(timer);
}, [connected, state?.singboxStartedAt]);
useEffect(() => {
if (!servers.length) {
setPings({});
return undefined;
}
let cancelled = false;
setPings(Object.fromEntries(servers.map((server) => [server.id, { checking: true }])));
api.servers.pingAll()
.then((data) => {
if (cancelled) return;
setPings(Object.fromEntries((data.results || []).map((ping) => [
ping.id || ping.tag,
ping,
])));
})
.catch(() => {
if (!cancelled) {
setPings(Object.fromEntries(servers.map((server) => [server.id, { ok: false }])));
}
});
return () => { cancelled = true; };
}, [serverKey]);
useEffect(() => {
if (editingSubscription) subscriptionInputRef.current?.focus();
}, [editingSubscription]);
@@ -916,7 +890,7 @@ export function ClientOverviewPage({
requestAnimationFrame(() => setUsageUpdated(true));
setTimeout(() => setUsageUpdated(false), 900);
setServersLeaving(true);
await new Promise((resolve) => setTimeout(resolve, 420 + Math.max(0, servers.length - 1) * 90));
await new Promise((resolve) => setTimeout(resolve, 420 + Math.min(7, Math.max(0, servers.length - 1)) * 90));
setServerRevealVersion((version) => version + 1);
setServersLeaving(false);
} finally {
@@ -1376,40 +1350,15 @@ export function ClientOverviewPage({
)}
{hasSubscription && subscriptionContentReady && (
<section className="client-servers" aria-label="Выберите сервер">
{!showPower && <span className="client-server-prompt">Выберите сервер</span>}
<div
className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`}
key={`${serverKey}:${serverRevealVersion}`}
>
{servers.map((server, index) => {
const ping = pings[server.id];
const selected = server.id === selectedServerId;
const pingText = ping?.checking
? 'Проверка…'
: ping?.ok ? `${ping.latency} ms` : 'Недоступен';
const pingClass = ping?.ok
? ping.latency < 100 ? 'good' : ping.latency < 250 ? 'medium' : 'slow'
: '';
return (
<button
className={`client-server ${selected ? 'is-selected' : ''}`}
type="button"
key={server.id}
disabled={serverApplyBlocked}
aria-pressed={selected}
aria-label={`${server.label}, ${server.host}:${server.port}, ${pingText}`}
style={{ '--server-index': index }}
onClick={() => selectServer(server.id)}
>
<strong>{server.label}</strong>
<small className={pingClass}>{pingText}</small>
</button>
);
})}
</div>
</section>
<ServerPicker
servers={servers}
selectedServerId={selectedServerId}
disabled={serverApplyBlocked}
prompt={!showPower}
leaving={serversLeaving}
revealVersion={serverRevealVersion}
onSelect={selectServer}
/>
)}
</div>
</div>

View File

@@ -0,0 +1,257 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import {
autoServer,
filterServers,
groupServers,
SERVER_RESULT_WINDOW,
} from '../utils/serverPicker.js';
const FAVORITES_KEY = 'harbor-server-favorites';
const RECENT_KEY = 'harbor-server-recent';
const AUTO_KEY = 'harbor-server-auto';
function readList(key) {
try {
const value = JSON.parse(localStorage.getItem(key) || '[]');
return Array.isArray(value) ? value.map(String) : [];
} catch {
return [];
}
}
function write(key, value) {
try {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
} catch {
// Preferences remain available for this session.
}
}
function readAuto() {
try {
return localStorage.getItem(AUTO_KEY) === 'true';
} catch {
return false;
}
}
function ServerRow({ server, selected, favorite, ping, disabled, index, onSelect, onFavorite }) {
const health = ping?.checking
? 'Проверяем TCP…'
: ping?.error ? 'Проверка недоступна'
: ping?.ok ? `TCP ${ping.latency} мс` : ping ? 'TCP недоступен' : 'Не проверен';
return <div className={`client-server-row${selected ? ' is-selected' : ''}${onFavorite ? ' has-favorite' : ''}`}>
<button
className={`client-server${selected ? ' is-selected' : ''}`}
type="button"
disabled={disabled}
aria-pressed={selected}
aria-label={`${server.label}, ${server.host}:${server.port}, ${health}`}
style={{ '--server-index': Math.min(index, 7) }}
onClick={() => onSelect(server.id)}
>
<strong>{server.label}</strong>
<small title={ping?.checkedAt || undefined}>{health}</small>
</button>
{onFavorite && <button
className={`client-server-favorite${favorite ? ' is-active' : ''}`}
type="button"
aria-pressed={favorite}
aria-label={`${favorite ? 'Убрать из избранного' : 'Добавить в избранное'}: ${server.label}`}
onClick={() => onFavorite(server.id)}
></button>}
</div>;
}
export function ServerPicker({
servers,
selectedServerId,
disabled,
prompt,
leaving,
revealVersion,
onSelect,
}) {
const [query, setQuery] = useState('');
const [view, setView] = useState('all');
const [page, setPage] = useState(0);
const [favorites, setFavorites] = useState(() => readList(FAVORITES_KEY));
const [recent, setRecent] = useState(() => readList(RECENT_KEY));
const [autoActive, setAutoActive] = useState(readAuto);
const [collapsed, setCollapsed] = useState([]);
const [pings, setPings] = useState({});
const [checking, setChecking] = useState(false);
const serverKey = servers.map(({ id }) => id).join('|');
useEffect(() => {
setPage(0);
}, [query, view, serverKey]);
const selected = servers.find(({ id }) => id === selectedServerId);
const filtered = useMemo(() => {
const found = filterServers(servers, query);
if (view === 'favorites') return found.filter(({ id }) => favorites.includes(id));
if (view === 'recent') return recent.flatMap((id) => found.find((server) => server.id === id) || []);
return found;
}, [servers, query, view, favorites, recent]);
const results = filtered.filter(({ id }) => id !== selectedServerId);
const pageCount = Math.max(1, Math.ceil(results.length / SERVER_RESULT_WINDOW));
const visible = results.slice(page * SERVER_RESULT_WINDOW, (page + 1) * SERVER_RESULT_WINDOW);
const grouped = servers.length >= 10;
useEffect(() => {
setPage((current) => Math.min(current, pageCount - 1));
}, [pageCount]);
function toggleFavorite(id) {
setFavorites((current) => {
const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current];
write(FAVORITES_KEY, next);
return next;
});
}
function select(id, automatic = false) {
setAutoActive(automatic);
write(AUTO_KEY, String(automatic));
if (!automatic) {
setRecent((current) => {
const next = [id, ...current.filter((item) => item !== id)].slice(0, 5);
write(RECENT_KEY, next);
return next;
});
}
onSelect(id);
}
async function checkVisible() {
const ids = [...new Set([selectedServerId, ...visible.map(({ id }) => id)].filter(Boolean))].slice(0, 30);
if (!ids.length) return;
setChecking(true);
setPings((current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, { checking: true }])),
}));
try {
const data = await api.servers.ping(ids);
setPings((current) => ({
...current,
...Object.fromEntries((data.results || []).map((result) => [result.id, result])),
}));
} catch {
setPings((current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, { error: true, checkedAt: new Date().toISOString() }])),
}));
} finally {
setChecking(false);
}
}
if (servers.length === 1) {
return <section className="client-servers" aria-label="Выберите сервер">
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
<div className="client-server-grid">
<ServerRow
server={servers[0]}
selected={servers[0].id === selectedServerId}
favorite={false}
disabled={disabled}
index={0}
onSelect={onSelect}
/>
</div>
</section>;
}
const renderRows = (items, offset = 0) => items.map((server, index) => (
<ServerRow
key={server.id}
server={server}
selected={!autoActive && server.id === selectedServerId}
favorite={favorites.includes(server.id)}
ping={pings[server.id]}
disabled={disabled}
index={offset + index}
onSelect={select}
onFavorite={toggleFavorite}
/>
));
return <section className="client-servers is-scalable" aria-label="Выберите сервер">
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
<div className="client-server-tools">
<input
type="search"
value={query}
aria-label="Найти сервер"
placeholder="Поиск сервера"
onChange={(event) => setQuery(event.target.value)}
/>
<div className="client-server-filters" aria-label="Фильтр серверов">
{[
['all', 'Все'],
['favorites', '★'],
['recent', 'Недавние'],
].map(([id, label]) => <button
type="button"
className={view === id ? 'is-active' : ''}
aria-pressed={view === id}
key={id}
onClick={() => setView(id)}
>{label}</button>)}
<button type="button" disabled={checking || !visible.length} onClick={checkVisible}>
{checking ? 'Проверяем…' : 'Проверить TCP'}
</button>
</div>
</div>
<div className="client-server-pinned">
<button
className={`client-server-auto${autoActive ? ' is-selected' : ''}`}
type="button"
aria-pressed={autoActive}
disabled={disabled || !servers.length}
onClick={() => select(autoServer(servers)?.id, true)}
>
<strong>Auto</strong>
<small>Первый стабильный сервер</small>
</button>
{selected && !autoActive && <ServerRow
server={selected}
selected
favorite={favorites.includes(selected.id)}
ping={pings[selected.id]}
disabled={disabled}
index={0}
onSelect={select}
onFavorite={toggleFavorite}
/>}
</div>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`${serverKey}:${revealVersion}`}>
{!visible.length && <p className="client-server-empty">Серверы не найдены</p>}
{grouped ? groupServers(visible).map(([group, items]) => {
const isCollapsed = collapsed.includes(group);
return <section className="client-server-group" key={group}>
<button
className="client-server-group-toggle"
type="button"
aria-expanded={!isCollapsed}
onClick={() => setCollapsed((current) => current.includes(group)
? current.filter((item) => item !== group)
: [...current, group])}
>{group} <small>{items.length}</small></button>
{!isCollapsed && <div className="client-server-grid">{renderRows(items)}</div>}
</section>;
}) : <div className="client-server-grid">{renderRows(visible)}</div>}
{pageCount > 1 && <nav className="client-server-pages" aria-label="Страницы серверов">
<button className="client-server-more" type="button" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Назад</button>
<span>{page + 1} / {pageCount}</span>
<button className="client-server-more" type="button" disabled={page + 1 === pageCount} onClick={() => setPage((current) => current + 1)}>Дальше</button>
</nav>}
</div>
</section>;
}