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:
+325
-56
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user