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}/); +});