Refactor server picker and limit ping requests
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
} from './gatewayPresence.js';
|
||||
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||
import { tcpPing } from './ping.js';
|
||||
import { checkServerHealth } from './serverHealth.js';
|
||||
import { buildSharedProxyInfo } from './sharedProxy.js';
|
||||
import {
|
||||
buildGatewayConfig,
|
||||
@@ -577,12 +578,12 @@ async function handleApi(req, res) {
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
|
||||
const state = stateStore.read();
|
||||
const results = await Promise.all((state.servers || []).map(async (server) => ({
|
||||
id: server.id,
|
||||
tag: server.label,
|
||||
...await tcpPing(server.host, server.port),
|
||||
checkedAt: new Date().toISOString(),
|
||||
})));
|
||||
const { serverIds = [] } = await readBody(req);
|
||||
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
|
||||
const servers = requestedIds.size
|
||||
? (state.servers || []).filter((server) => requestedIds.has(server.id))
|
||||
: state.servers || [];
|
||||
const results = await checkServerHealth(servers, tcpPing);
|
||||
return sendState(res, { results });
|
||||
}
|
||||
|
||||
|
||||
27
src/server/serverHealth.js
Normal file
27
src/server/serverHealth.js
Normal file
@@ -0,0 +1,27 @@
|
||||
export const SERVER_HEALTH_MAX_COUNT = 30;
|
||||
export const SERVER_HEALTH_CONCURRENCY = 4;
|
||||
|
||||
export async function checkServerHealth(servers, ping, {
|
||||
maxCount = SERVER_HEALTH_MAX_COUNT,
|
||||
concurrency = SERVER_HEALTH_CONCURRENCY,
|
||||
} = {}) {
|
||||
const queue = servers.slice(0, maxCount);
|
||||
const results = new Array(queue.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < queue.length) {
|
||||
const index = nextIndex++;
|
||||
const server = queue[index];
|
||||
results[index] = {
|
||||
id: server.id,
|
||||
tag: server.label,
|
||||
...await ping(server.host, server.port),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.7.11',
|
||||
gatewayClient: '0.7.11',
|
||||
gatewayBackend: '0.7.0',
|
||||
macClient: '0.7.12',
|
||||
gatewayClient: '0.7.12',
|
||||
gatewayBackend: '0.7.1',
|
||||
});
|
||||
|
||||
export function parseVersion(value) {
|
||||
|
||||
@@ -79,6 +79,9 @@ export const api = {
|
||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||
},
|
||||
servers: {
|
||||
pingAll: () => request('/api/servers/ping-all', { method: 'POST' }),
|
||||
ping: (serverIds) => request('/api/servers/ping-all', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ serverIds }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
<ServerPicker
|
||||
servers={servers}
|
||||
selectedServerId={selectedServerId}
|
||||
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>
|
||||
prompt={!showPower}
|
||||
leaving={serversLeaving}
|
||||
revealVersion={serverRevealVersion}
|
||||
onSelect={selectServer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
257
src/web/components/ServerPicker.jsx
Normal file
257
src/web/components/ServerPicker.jsx
Normal 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>;
|
||||
}
|
||||
@@ -2638,14 +2638,6 @@ p {
|
||||
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.client-server-grid.is-leaving {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.client-server-grid.is-leaving .client-server {
|
||||
animation: client-server-leave 420ms calc(var(--server-index) * 90ms) cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes client-server-leave {
|
||||
0% {
|
||||
opacity: 1;
|
||||
@@ -2703,9 +2695,192 @@ p {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-server small.good { color: var(--client-accent); }
|
||||
.client-server small.medium { color: oklch(0.72 0.12 80); }
|
||||
.client-server small.slow { color: oklch(0.65 0.16 30); }
|
||||
.client-servers.is-scalable {
|
||||
width: min(100%, 360px);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.client-server-tools {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.client-server-tools input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--client-border);
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
font: 500 11px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.client-server-tools input:focus {
|
||||
border-bottom-color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-server-filters {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.client-server-filters button,
|
||||
.client-server-group-toggle,
|
||||
.client-server-more {
|
||||
min-height: 30px;
|
||||
padding: 5px 7px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-muted);
|
||||
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-server-filters button.is-active,
|
||||
.client-server-filters button:hover:not(:disabled),
|
||||
.client-server-group-toggle:hover,
|
||||
.client-server-more:hover {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-server-filters button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.client-server-pinned {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.client-server-auto {
|
||||
min-height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 3px;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--client-border);
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-server-auto.is-selected {
|
||||
border-bottom: 2px solid var(--client-accent);
|
||||
}
|
||||
|
||||
.client-server-auto strong {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.client-server-auto small {
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.client-server-scroll {
|
||||
max-height: 330px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 5px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklch, var(--client-accent) 36%, transparent) transparent;
|
||||
}
|
||||
|
||||
.client-server-scroll.is-leaving {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.client-server-scroll.is-leaving .client-server {
|
||||
animation: client-server-leave 420ms calc(var(--server-index) * 45ms) cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
.client-server-group + .client-server-group {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.client-server-group-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.client-server-group-toggle small {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.client-servers.is-scalable .client-server-grid {
|
||||
width: 100%;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.client-server-row.has-favorite {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 38px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.client-server-row .client-server {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.client-server-favorite {
|
||||
width: 38px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-server-favorite.is-active {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-server-empty {
|
||||
padding: 24px 4px;
|
||||
color: var(--client-muted);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-server-more {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.client-server-pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.client-server-more:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.client-server-filters button:focus-visible,
|
||||
.client-server-group-toggle:focus-visible,
|
||||
.client-server-more:focus-visible,
|
||||
.client-server-auto:focus-visible,
|
||||
.client-server-favorite:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.client-server:disabled {
|
||||
cursor: wait;
|
||||
@@ -3104,6 +3279,13 @@ p {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.client-server-filters button,
|
||||
.client-server-group-toggle,
|
||||
.client-server-more,
|
||||
.client-server-favorite {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.client-local-rule {
|
||||
grid-template-columns: 44px minmax(0, 1fr) 44px;
|
||||
}
|
||||
@@ -3192,6 +3374,10 @@ p {
|
||||
.client-usage,
|
||||
.client-usage > strong,
|
||||
.client-server,
|
||||
.client-server-auto,
|
||||
.client-server-favorite,
|
||||
.client-server-group-toggle,
|
||||
.client-server-more,
|
||||
.client-copy-button,
|
||||
.client-access-tab,
|
||||
.client-access-point,
|
||||
|
||||
31
src/web/utils/serverPicker.js
Normal file
31
src/web/utils/serverPicker.js
Normal file
@@ -0,0 +1,31 @@
|
||||
export const SERVER_RESULT_WINDOW = 60;
|
||||
|
||||
const searchable = (server) => [
|
||||
server.label,
|
||||
server.host,
|
||||
server.country,
|
||||
server.city,
|
||||
server.provider,
|
||||
server.protocol,
|
||||
].filter(Boolean).join(' ').toLocaleLowerCase('ru');
|
||||
|
||||
export function filterServers(servers, query) {
|
||||
const needle = String(query || '').trim().toLocaleLowerCase('ru');
|
||||
return needle ? servers.filter((server) => searchable(server).includes(needle)) : servers;
|
||||
}
|
||||
|
||||
export function serverGroup(server) {
|
||||
return server.country || server.provider || 'Другие';
|
||||
}
|
||||
|
||||
export function groupServers(servers) {
|
||||
return [...servers.reduce((groups, server) => {
|
||||
const name = serverGroup(server);
|
||||
groups.set(name, [...(groups.get(name) || []), server]);
|
||||
return groups;
|
||||
}, new Map())];
|
||||
}
|
||||
|
||||
export function autoServer(servers) {
|
||||
return [...servers].sort((left, right) => left.id.localeCompare(right.id))[0] || null;
|
||||
}
|
||||
26
test/server/server-health.test.js
Normal file
26
test/server/server-health.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { checkServerHealth } from '../../src/server/serverHealth.js';
|
||||
|
||||
test('server health checks cap count and concurrency', async () => {
|
||||
const servers = Array.from({ length: 300 }, (_, index) => ({
|
||||
id: `srv-${index}`,
|
||||
label: `Server ${index}`,
|
||||
host: `server-${index}.example`,
|
||||
port: 443,
|
||||
}));
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const results = await checkServerHealth(servers, async () => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
active -= 1;
|
||||
return { ok: true, latency: 1 };
|
||||
});
|
||||
|
||||
assert.equal(results.length, 30);
|
||||
assert.equal(peak, 4);
|
||||
assert.deepEqual(results.map(({ id }) => id), servers.slice(0, 30).map(({ id }) => id));
|
||||
});
|
||||
49
test/web/server-picker.test.js
Normal file
49
test/web/server-picker.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
autoServer,
|
||||
filterServers,
|
||||
groupServers,
|
||||
SERVER_RESULT_WINDOW,
|
||||
} from '../../src/web/utils/serverPicker.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const picker = fs.readFileSync(path.join(root, 'src/web/components/ServerPicker.jsx'), 'utf8');
|
||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||
|
||||
const fixtures = (count) => Array.from({ length: count }, (_, index) => ({
|
||||
id: `srv-${String(count - index).padStart(3, '0')}`,
|
||||
label: index < 2 ? 'Duplicate' : `Server ${index}`,
|
||||
host: `node-${index}.example`,
|
||||
country: index % 2 ? 'NL' : 'DE',
|
||||
provider: index % 3 ? 'Harbor' : 'Other',
|
||||
protocol: index % 2 ? 'vless' : 'trojan',
|
||||
}));
|
||||
|
||||
test('server picker handles 1, 30 and 300 stable-ID servers with duplicate labels', () => {
|
||||
for (const count of [1, 30, 300]) {
|
||||
const servers = fixtures(count);
|
||||
assert.equal(filterServers(servers, '').length, count);
|
||||
assert.equal(new Set(servers.map(({ id }) => id)).size, count);
|
||||
}
|
||||
assert.equal(filterServers(fixtures(30), 'node-12.example')[0].id, 'srv-018');
|
||||
assert.equal(filterServers(fixtures(30), 'trojan').length, 15);
|
||||
assert.equal(groupServers(fixtures(30)).length, 2);
|
||||
assert.equal(autoServer(fixtures(30)).id, 'srv-001');
|
||||
assert.equal(SERVER_RESULT_WINDOW, 60);
|
||||
});
|
||||
|
||||
test('server picker keeps health manual and the rendered result window bounded', () => {
|
||||
assert.doesNotMatch(overview, /pingAll|servers\.ping/);
|
||||
assert.match(picker, /onClick={checkVisible}/);
|
||||
assert.match(picker, /\.slice\(page \* SERVER_RESULT_WINDOW, \(page \+ 1\) \* SERVER_RESULT_WINDOW\)/);
|
||||
assert.match(picker, /\.slice\(0, 30\)/);
|
||||
assert.match(picker, /Math\.min\(index, 7\)/);
|
||||
assert.match(picker, /type="search"/);
|
||||
assert.match(picker, /harbor-server-favorites/);
|
||||
assert.match(picker, /harbor-server-recent/);
|
||||
assert.match(picker, /aria-expanded={!isCollapsed}/);
|
||||
});
|
||||
Reference in New Issue
Block a user