diff --git a/src/server/index.js b/src/server/index.js
index 9f85efc..11346aa 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -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 });
}
diff --git a/src/server/serverHealth.js b/src/server/serverHealth.js
new file mode 100644
index 0000000..a7ca582
--- /dev/null
+++ b/src/server/serverHealth.js
@@ -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;
+}
diff --git a/src/shared/versions.js b/src/shared/versions.js
index 5f46cc5..364eb9b 100644
--- a/src/shared/versions.js
+++ b/src/shared/versions.js
@@ -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) {
diff --git a/src/web/api.js b/src/web/api.js
index f58bb31..16b57a2 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -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 }),
+ }),
},
};
diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx
index 6e14f4e..3754e93 100644
--- a/src/web/components/ClientOverviewPage.jsx
+++ b/src/web/components/ClientOverviewPage.jsx
@@ -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 && (
-
- {!showPower && Выберите сервер}
-
- {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 (
-
- );
- })}
-
-
+
)}
diff --git a/src/web/components/ServerPicker.jsx b/src/web/components/ServerPicker.jsx
new file mode 100644
index 0000000..e63a340
--- /dev/null
+++ b/src/web/components/ServerPicker.jsx
@@ -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
+
+ {onFavorite && }
+
;
+}
+
+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
+ {prompt && Выберите сервер}
+
+
+
+ ;
+ }
+
+ const renderRows = (items, offset = 0) => items.map((server, index) => (
+
+ ));
+
+ return
+ {prompt && Выберите сервер}
+
+
setQuery(event.target.value)}
+ />
+
+ {[
+ ['all', 'Все'],
+ ['favorites', '★'],
+ ['recent', 'Недавние'],
+ ].map(([id, label]) => )}
+
+
+
+
+
+
+ {selected && !autoActive && }
+
+
+
+ {!visible.length &&
Серверы не найдены
}
+ {grouped ? groupServers(visible).map(([group, items]) => {
+ const isCollapsed = collapsed.includes(group);
+ return
+
+ {!isCollapsed && {renderRows(items)}
}
+ ;
+ }) :
{renderRows(visible)}
}
+ {pageCount > 1 &&
}
+
+ ;
+}
diff --git a/src/web/styles.css b/src/web/styles.css
index b0e281f..9334205 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -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,
diff --git a/src/web/utils/serverPicker.js b/src/web/utils/serverPicker.js
new file mode 100644
index 0000000..ed608fa
--- /dev/null
+++ b/src/web/utils/serverPicker.js
@@ -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;
+}
diff --git a/test/server/server-health.test.js b/test/server/server-health.test.js
new file mode 100644
index 0000000..471ec17
--- /dev/null
+++ b/test/server/server-health.test.js
@@ -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));
+});
diff --git a/test/web/server-picker.test.js b/test/web/server-picker.test.js
new file mode 100644
index 0000000..1a31a36
--- /dev/null
+++ b/test/web/server-picker.test.js
@@ -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}/);
+});