Release 2.2.0: Happ-style multi-node list with ping and auto best connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-07-29 07:39:46 +03:00
co-authored by Cursor
parent 37e31ae742
commit 8c513f6e08
17 changed files with 527 additions and 99 deletions
+325 -56
View File
@@ -446,6 +446,143 @@
}
.tools .action { min-height: 44px; font-size: .9rem; }
.server-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.server-toolbar .section-title { margin: 0; }
.server-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.server-actions .mini {
appearance: none;
border: 1px solid rgba(8, 90, 68, 0.22);
background: linear-gradient(180deg, #d2efe4, #bfe6d6);
color: var(--accent-deep);
border-radius: 999px;
padding: 6px 10px;
font: inherit;
font-size: .72rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.server-actions .mini:hover { filter: brightness(.97); }
.server-actions .mini.accent {
background: linear-gradient(180deg, #2f6f5f, #25584b);
color: #f2fff9;
border-color: rgba(12, 48, 40, 0.35);
}
.auto-best {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: .82rem;
font-weight: 600;
color: var(--muted);
}
.server-list {
max-height: min(42vh, 360px);
overflow: auto;
display: grid;
gap: 3px;
margin-bottom: 10px;
padding: 4px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255,255,255,.4);
}
.server-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid transparent;
background: rgba(255,255,255,.72);
cursor: pointer;
transition: background .12s, border-color .12s, box-shadow .12s;
text-align: left;
width: 100%;
font: inherit;
color: inherit;
}
.server-row:hover {
border-color: rgba(13,138,102,.25);
background: #fff;
}
.server-row.active {
border-color: rgba(13,138,102,.4);
background: linear-gradient(135deg, rgba(216,243,233,.95), #fff);
box-shadow: 0 4px 14px rgba(13,138,102,.1);
}
.server-row .left { min-width: 0; }
.server-row .name {
font-weight: 700;
font-size: .84rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .sub {
margin-top: 2px;
font-size: .72rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex: 0 0 auto;
}
.server-row .proto {
font-size: .62rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--accent-deep);
background: var(--accent-soft);
border-radius: 999px;
padding: 2px 6px;
}
.server-row .ms {
font-size: .78rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.server-row .ms.ok { color: var(--ok); }
.server-row .ms.good { color: #0a7a3e; }
.server-row .ms.mid { color: #b8860b; }
.server-row .ms.bad { color: var(--danger); }
.server-empty {
padding: 14px 10px;
text-align: center;
color: var(--muted);
font-size: .82rem;
}
.profile-edit summary {
cursor: pointer;
list-style: none;
font-size: .78rem;
font-weight: 700;
color: var(--muted);
padding: 4px 0;
}
.profile-edit summary::-webkit-details-marker { display: none; }
.meta {
margin: 10px 0 0;
font-size: .82rem;
@@ -588,7 +725,7 @@
<div class="brand-wrap">
<div class="brand-row">
<h1 class="brand">Navis</h1>
<span class="badge-ver">2.1</span>
<span class="badge-ver">2.2</span>
</div>
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
</div>
@@ -609,31 +746,43 @@
</section>
<section class="section">
<div class="section-title"><span>Сервер</span></div>
<label class="field" for="profile">Профиль</label>
<div class="profile-bar">
<select id="profile"></select>
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль">+</button>
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить профиль">×</button>
<div class="server-toolbar">
<div class="section-title"><span>Серверы</span></div>
<div class="server-actions">
<button class="mini" id="pingBtn" type="button">Пинг</button>
<button class="mini accent" id="bestBtn" type="button">Лучший</button>
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль" style="width:34px;height:34px;font-size:1rem">+</button>
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</button>
</div>
</div>
<label class="auto-best">
<input id="autoBest" type="checkbox" checked />
Авто: пинг + подключение к лучшему
</label>
<div class="server-list" id="serverList"></div>
<select id="profile" hidden></select>
<div class="stack">
<div>
<label class="field" for="name">Название</label>
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
</div>
<div>
<label class="field" for="proxy">Ссылка / конфиг</label>
<textarea id="proxy" rows="3" placeholder="vless:// · vmess:// · trojan:// · hy2:// · naive+https:// · AWG .conf" spellcheck="false"></textarea>
</div>
<div>
<label class="field" for="subUrl">Подписка</label>
<div class="sub-bar">
<input id="subUrl" type="text" placeholder="https://…/sub" spellcheck="false" />
<button class="icon-btn wide" id="subBtn" type="button" title="Обновить подписку">Обновить</button>
<details class="profile-edit" id="profileEdit">
<summary>Редактировать выбранный / подписка</summary>
<div class="stack" style="margin-top:8px">
<div>
<label class="field" for="name">Название</label>
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
</div>
<div>
<label class="field" for="proxy">Ссылка / конфиг</label>
<textarea id="proxy" rows="3" placeholder="vless:// · vmess:// · trojan:// · hy2:// · naive+https:// · AWG .conf" spellcheck="false"></textarea>
</div>
<div>
<label class="field" for="subUrl">Подписка</label>
<div class="sub-bar">
<input id="subUrl" type="text" placeholder="https://…/sub" spellcheck="false" />
<button class="icon-btn wide" id="subBtn" type="button" title="Обновить подписку">Обновить</button>
</div>
</div>
<button class="action secondary" id="saveBtn" type="button">Сохранить профиль</button>
</div>
</div>
</details>
</section>
<div class="row">
@@ -644,10 +793,6 @@
</label>
</div>
<div class="actions">
<button class="action secondary" id="saveBtn" type="button">Сохранить профиль</button>
</div>
<details class="panel hy2" id="hy2Box">
<summary>Hysteria 2 · BBR / obfuscation</summary>
<div class="grid2">
@@ -705,15 +850,11 @@
</details>
<details class="panel" id="toolsBox">
<summary>Сервис · пинг, cores, обновления</summary>
<summary>Сервис · cores, обновления</summary>
<div class="tools">
<button class="action secondary" id="pingBtn" type="button">Пинг серверов</button>
<button class="action secondary" id="updCheckBtn" type="button">Проверить обновление</button>
<button class="action secondary" id="coreBtn" type="button">Установить cores</button>
</div>
<div style="margin-top:8px">
<button class="action secondary" id="coreBtn" type="button" style="width:100%">Установить cores (naive + hy2 + xray)</button>
</div>
<div class="ping-list" id="pingList"></div>
</details>
<p class="meta" id="meta">Загрузка…</p>
@@ -765,10 +906,12 @@
const shopBtn = $("shopBtn");
const shopLink = $("shopLink");
const pingBtn = $("pingBtn");
const bestBtn = $("bestBtn");
const autoBest = $("autoBest");
const serverList = $("serverList");
const updCheckBtn = $("updCheckBtn");
const updateBanner = $("updateBanner");
const updateBtn = $("updateBtn");
const pingList = $("pingList");
const verLabel = $("verLabel");
const subUrl = $("subUrl");
const subBtn = $("subBtn");
@@ -776,6 +919,16 @@
const protoChip = $("protoChip");
const heroHint = $("heroHint");
const SHOP_URL = "https://evilfox.win/";
const AUTO_BEST_KEY = "navis.autoBest";
try {
const saved = localStorage.getItem(AUTO_BEST_KEY);
if (saved === "0") autoBest.checked = false;
if (saved === "1") autoBest.checked = true;
} catch (_) {}
autoBest.addEventListener("change", () => {
try { localStorage.setItem(AUTO_BEST_KEY, autoBest.checked ? "1" : "0"); } catch (_) {}
});
function readHy2() {
return {
@@ -823,6 +976,7 @@
let formHydrated = false;
let dirty = false;
let profiles = [];
let pingMap = {};
let metaHoldUntil = 0;
function markDirty() { dirty = true; }
@@ -858,6 +1012,19 @@
return "протокол";
}
function msClass(ms, ok) {
if (!ok) return "bad";
if (ms < 80) return "good";
if (ms < 180) return "ok";
if (ms < 350) return "mid";
return "bad";
}
function rememberPings(pings) {
pingMap = {};
(pings || []).forEach((p) => { pingMap[p.name] = p; });
}
function fillProfiles(list, active) {
profiles = list || [];
const cur = profile.value;
@@ -865,31 +1032,103 @@
profiles.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.name;
const proto = p.protocol ? String(p.protocol).toUpperCase() : "";
opt.textContent = (proto ? (proto + " · ") : "") + (p.host ? (p.name + " · " + p.host) : p.name);
opt.textContent = p.name;
profile.appendChild(opt);
});
const want = active || cur || (profiles[0] && profiles[0].name);
if (want) profile.value = want;
renderServerList(want);
}
function renderPings(pings) {
pingList.innerHTML = "";
(pings || []).forEach((p) => {
const row = document.createElement("div");
row.className = "ping-item";
const left = document.createElement("span");
left.textContent = p.host ? (p.name + " · " + p.host) : p.name;
const right = document.createElement("span");
right.className = "ms " + (p.ok ? "ok" : "bad");
right.textContent = p.ok ? (p.ms + " ms") : (p.error || "ошибка");
function orderedProfiles() {
const list = profiles.slice();
list.sort((a, b) => {
const pa = pingMap[a.name], pb = pingMap[b.name];
const oa = pa && pa.ok, ob = pb && pb.ok;
if (oa !== ob) return oa ? -1 : 1;
if (oa && ob && pa.ms !== pb.ms) return pa.ms - pb.ms;
return String(a.name).localeCompare(String(b.name));
});
return list;
}
function renderServerList(activeName) {
const active = activeName || profile.value;
serverList.innerHTML = "";
const list = orderedProfiles();
if (!list.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = "Нет серверов — добавьте профиль или подписку";
serverList.appendChild(empty);
return;
}
list.forEach((p) => {
const row = document.createElement("button");
row.type = "button";
row.className = "server-row" + (p.name === active ? " active" : "");
row.dataset.name = p.name;
const left = document.createElement("div");
left.className = "left";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = p.name;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = p.host || "нет хоста";
left.appendChild(nameEl);
left.appendChild(sub);
const right = document.createElement("div");
right.className = "right";
const proto = document.createElement("span");
proto.className = "proto";
proto.textContent = (p.protocol || "?").toString();
const ms = document.createElement("span");
const pr = pingMap[p.name];
if (pr && pr.ok) {
ms.className = "ms " + msClass(pr.ms, true);
ms.textContent = pr.ms + " ms";
} else if (pr && pr.error) {
ms.className = "ms bad";
ms.textContent = "—";
ms.title = pr.error;
} else {
ms.className = "ms";
ms.textContent = "…";
}
right.appendChild(proto);
right.appendChild(ms);
row.appendChild(left);
row.appendChild(right);
pingList.appendChild(row);
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
serverList.appendChild(row);
});
if ((pings || []).length && $("toolsBox") && !$("toolsBox").open) {
$("toolsBox").open = true;
}
async function onServerClick(name) {
if (busy) return;
if (connected) {
if (name !== profile.value) await onServerConnect(name);
return;
}
await withBusy(async () => {
await selectProfile(name);
dirty = false;
formHydrated = false;
});
}
async function onServerConnect(name) {
if (busy) return;
await withBusy(async () => {
setMeta("Подключение к «" + name + "»…");
await connectProfile(name);
setMeta("Подключено: " + name, "ok");
});
}
function renderUpdate(u, version) {
@@ -928,14 +1167,15 @@
saveBtn.disabled = lock;
coreBtn.disabled = busy;
pingBtn.disabled = busy;
bestBtn.disabled = busy;
updCheckBtn.disabled = busy;
updateBtn.disabled = busy;
subBtn.disabled = busy;
subUrl.disabled = busy;
btn.disabled = busy;
rememberPings(state.pings || []);
fillProfiles(state.profiles || [], state.active_profile || state.profile);
renderPings(state.pings || []);
renderUpdate(state.update, state.version);
if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url;
@@ -969,8 +1209,9 @@
detail = "Сначала установите cores в разделе «Сервис»";
heroHint.textContent = "Нужны cores для выбранного протокола";
} else {
detail = "Готово к подключению";
heroHint.textContent = "Выберите профиль и нажмите Подключить";
const n = (state.profiles || []).length;
detail = n > 1 ? ("Серверов: " + n + " · нажмите Пинг или Лучший") : "Готово к подключению";
heroHint.textContent = n > 1 ? "Клик — выбрать, двойной клик — подключить" : "Выберите сервер и нажмите Подключить";
}
if (!busy && Date.now() > metaHoldUntil) {
setMeta(detail, state.core_ready === false ? "err" : "");
@@ -1002,11 +1243,22 @@
}
function paintButtonsLocked(locked) {
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn, subBtn, subUrl].forEach((b) => {
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, subBtn, subUrl].forEach((b) => {
if (b) b.disabled = locked;
});
}
async function runBest(autoConnect) {
setMeta(autoConnect ? "Пинг и автоподключение…" : "Пинг и выбор лучшего…");
const res = await pingBest(!!autoConnect);
rememberPings(res.pings || []);
renderServerList(res.best_name || profile.value);
if (res.best_name) {
setMeta("Лучший: " + res.best_name + " · " + res.best_ms + " ms" + (res.connected ? " · подключено" : ""), "ok");
}
return res;
}
profile.addEventListener("change", () => withBusy(async () => {
await selectProfile(profile.value);
dirty = false;
@@ -1104,7 +1356,8 @@
const n = await importSubscription(url);
formHydrated = false;
dirty = false;
setMeta("Импортировано профилей: " + n, "ok");
setMeta("Импортировано: " + n + " · измеряю пинг…", "ok");
await runBest(!!autoBest.checked);
}
subBtn.addEventListener("click", () => withBusy(async () => {
@@ -1121,14 +1374,21 @@
pingBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta(роверка серверов…");
setMeta(инг серверов…");
const rows = await pingServers();
renderPings(rows);
rememberPings(rows);
renderServerList(profile.value);
const ok = (rows || []).filter((r) => r.ok).length;
setMeta("Пинг: " + ok + "/" + (rows || []).length + " доступны", ok ? "ok" : "err");
} catch (e) { setMeta(String(e), "err"); }
}));
bestBtn.addEventListener("click", () => withBusy(async () => {
try {
await runBest(!!autoBest.checked);
} catch (e) { setMeta(String(e), "err"); }
}));
updCheckBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Проверка обновлений…");
@@ -1148,7 +1408,16 @@
} catch (e) { setMeta(String(e), "err"); }
}));
refresh({ syncForm: true }).catch((e) => setMeta(String(e), "err"));
(async () => {
try {
await refresh({ syncForm: true });
if (profiles.length > 1 && autoBest.checked && !connected) {
await withBusy(async () => { await runBest(true); });
}
} catch (e) {
setMeta(String(e), "err");
}
})();
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 2500);
</script>
</body>
+72 -5
View File
@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"net"
"sort"
"strings"
"sync"
"time"
"vpnclient/internal/config"
@@ -24,11 +26,78 @@ type Result struct {
Error string `json:"error,omitempty"`
}
// Target describes one profile to ping.
type Target struct {
Name string
Protocol config.Protocol
Proxy string
}
// PingProxyURI measures connect time to the host in a share/proxy URI.
func PingProxyURI(ctx context.Context, name, proxyURI string) Result {
return PingProfile(ctx, name, "", proxyURI)
}
// PingAll pings targets in parallel (bounded concurrency) and sorts OK results by latency.
func PingAll(ctx context.Context, targets []Target) []Result {
out := make([]Result, len(targets))
if len(targets) == 0 {
return out
}
workers := 12
if len(targets) < workers {
workers = len(targets)
}
sem := make(chan struct{}, workers)
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
go func(i int, t Target) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
return
}
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy)
}(i, t)
}
wg.Wait()
sorted := append([]Result(nil), out...)
sort.SliceStable(sorted, func(i, j int) bool {
a, b := sorted[i], sorted[j]
if a.OK != b.OK {
return a.OK
}
if !a.OK {
return a.Name < b.Name
}
if a.Ms != b.Ms {
return a.Ms < b.Ms
}
return a.Name < b.Name
})
return sorted
}
// BestOK returns the lowest-latency successful result, if any.
func BestOK(results []Result) (Result, bool) {
var best Result
found := false
for _, r := range results {
if !r.OK {
continue
}
if !found || r.Ms < best.Ms {
best = r
found = true
}
}
return best, found
}
// PingProfile pings using protocol hints when available.
// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused".
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
@@ -111,7 +180,7 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
}
func probeTCP(ctx context.Context, host, port string) error {
d := net.Dialer{Timeout: 4 * time.Second}
d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
if err != nil {
return err
@@ -121,20 +190,19 @@ func probeTCP(ctx context.Context, host, port string) error {
}
func probeUDP(ctx context.Context, host, port string) error {
d := net.Dialer{Timeout: 4 * time.Second}
d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port))
if err != nil {
return err
}
defer conn.Close()
deadline := time.Now().Add(1500 * time.Millisecond)
deadline := time.Now().Add(1200 * time.Millisecond)
if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
deadline = dl
}
_ = conn.SetDeadline(deadline)
// Any datagram is enough to exercise the UDP path; QUIC rarely answers a bare probe.
if _, err := conn.Write([]byte{0}); err != nil {
return err
}
@@ -144,7 +212,6 @@ func probeUDP(ctx context.Context, host, port string) error {
return nil
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
// No ICMP unreachable → port is typically open or filtered; treat as reachable for UI ping.
return nil
}
return err
+1 -1
View File
@@ -17,7 +17,7 @@ import (
)
// CurrentVersion is the shipped client version.
const CurrentVersion = "2.1.0"
const CurrentVersion = "2.2.0"
// DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"