Release 4.2.4+1: group same-host profiles into one server row with protocol chips (VLESS · Trojan · Hysteria2) instead of «сервер 2/3»; оптимальный among distinct hosts. Windows 4.2.4.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+262
-44
@@ -874,6 +874,33 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.server-row .protos {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.server-row .proto-tag {
|
||||
display: inline-block;
|
||||
font-size: .62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .02em;
|
||||
color: var(--muted);
|
||||
background: var(--meta-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 2px 7px;
|
||||
cursor: pointer;
|
||||
line-height: 1.35;
|
||||
user-select: none;
|
||||
}
|
||||
.server-row .proto-tag:hover { border-color: rgba(8,145,178,.45); color: var(--text); }
|
||||
.server-row .proto-tag.selected {
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-soft);
|
||||
border-color: rgba(8,145,178,.35);
|
||||
}
|
||||
.server-row .right { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
||||
/* Протокол — крошечная буква-иконка с подсказкой (не повторяем «vless» под каждой строкой). */
|
||||
.proto-badge {
|
||||
@@ -2281,35 +2308,168 @@
|
||||
}
|
||||
|
||||
// Человекочитаемые имена серверов («Чехия — оптимальный сервер», «Германия — сервер 2»).
|
||||
// Реальный host:port показываем только в свойствах сервера и в журнале.
|
||||
// ISO всегда через profileISO (бэкенд + JS-алиасы опечаток) → countryRu, никогда сырое имя панели.
|
||||
// Профили с одним физическим хостом (IP или hostname) группируются в одну строку;
|
||||
// протоколы (VLESS / Trojan / Hysteria2…) показываются чипами, а не «сервер 2/3».
|
||||
// Реальный host:port — только в свойствах сервера и в журнале.
|
||||
// ISO всегда через profileISO → countryRu, никогда сырое имя панели.
|
||||
let friendlyMap = {};
|
||||
|
||||
function isLiteralIP(host) {
|
||||
const h = String(host || "").replace(/^\[|\]$/g, "");
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(h)) return true;
|
||||
if (h.includes(":") && /^[0-9a-fA-F:.]+$/.test(h)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Хост без схемы/userinfo/порта — ключ группировки «один физический сервер».
|
||||
function normalizeEndpointHost(raw) {
|
||||
let s = String(raw || "").trim();
|
||||
if (!s) return "";
|
||||
const lower = s.toLowerCase();
|
||||
if (lower.includes("[interface]")) {
|
||||
for (const line of s.split(/\r?\n/)) {
|
||||
const t = line.trim();
|
||||
if (/^endpoint\s*[=:]/i.test(t)) {
|
||||
s = t.replace(/^endpoint\s*[=:]\s*/i, "").trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const scheme = s.indexOf("://");
|
||||
if (scheme >= 0) s = s.slice(scheme + 3);
|
||||
const at = s.lastIndexOf("@");
|
||||
if (at >= 0) s = s.slice(at + 1);
|
||||
const cut = s.search(/[/?#]/);
|
||||
if (cut >= 0) s = s.slice(0, cut);
|
||||
s = s.trim();
|
||||
let host = s;
|
||||
if (s.startsWith("[")) {
|
||||
const end = s.indexOf("]");
|
||||
if (end >= 0) host = s.slice(1, end);
|
||||
} else {
|
||||
const col = s.lastIndexOf(":");
|
||||
// Один «:» → host:port; несколько → скорее IPv6 без скобок.
|
||||
if (col > 0 && s.indexOf(":") === col) host = s.slice(0, col);
|
||||
}
|
||||
return host.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
// Ключ группы: ip:<addr> если известен IP (пинг или литерал в URI), иначе host:<hostname>.
|
||||
// Порт в ключ не входит — варианты протоколов на разных портах одного хоста = один сервер.
|
||||
function profileEndpointKey(p) {
|
||||
if (!p) return "";
|
||||
const pr = pingMap[p.name];
|
||||
if (pr && pr.host) {
|
||||
const ph = String(pr.host).replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (isLiteralIP(ph)) return "ip:" + ph;
|
||||
}
|
||||
const raw = (p.host && String(p.host).trim()) || (p.proxy && String(p.proxy).trim()) || "";
|
||||
const host = normalizeEndpointHost(raw);
|
||||
if (!host) return "id:" + String(p.name || "");
|
||||
if (isLiteralIP(host)) return "ip:" + host;
|
||||
return "host:" + host;
|
||||
}
|
||||
|
||||
function protoPretty(proto) {
|
||||
const p = String(proto || "").toLowerCase();
|
||||
const map = {
|
||||
vless: "VLESS", vmess: "VMess", trojan: "Trojan",
|
||||
hysteria2: "Hysteria2", hy2: "Hysteria2",
|
||||
naive: "Naive", awg: "AWG"
|
||||
};
|
||||
if (map[p]) return map[p];
|
||||
return p ? (p.charAt(0).toUpperCase() + p.slice(1)) : "?";
|
||||
}
|
||||
|
||||
function uniqueProtos(members) {
|
||||
const seen = {};
|
||||
const out = [];
|
||||
(members || []).forEach((m) => {
|
||||
const raw = String(m.protocol || "").toLowerCase() || "?";
|
||||
const label = protoPretty(raw);
|
||||
if (seen[label]) return;
|
||||
seen[label] = true;
|
||||
out.push({ raw: raw, label: label, name: m.name });
|
||||
});
|
||||
out.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return out;
|
||||
}
|
||||
|
||||
function groupBestPing(members) {
|
||||
let best = null;
|
||||
(members || []).forEach((m) => {
|
||||
const pr = pingMap[m.name];
|
||||
if (!pr) return;
|
||||
if (pr.ok) {
|
||||
if (!best || !best.ok || pr.ms < best.ms) best = pr;
|
||||
} else if (!best) {
|
||||
best = pr;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function pickGroupProfile(members, preferredName) {
|
||||
const list = members || [];
|
||||
if (!list.length) return null;
|
||||
if (preferredName && list.some((m) => m.name === preferredName)) {
|
||||
return list.find((m) => m.name === preferredName);
|
||||
}
|
||||
let best = list[0], bestMs = Infinity;
|
||||
list.forEach((m) => {
|
||||
const pr = pingMap[m.name];
|
||||
if (pr && pr.ok && pr.ms < bestMs) { bestMs = pr.ms; best = m; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function rebuildFriendlyNames() {
|
||||
friendlyMap = {};
|
||||
const groups = {};
|
||||
const byCountry = {};
|
||||
profiles.forEach((p) => {
|
||||
const key = profileISO(p) || "__none__";
|
||||
(groups[key] = groups[key] || []).push(p);
|
||||
const iso = profileISO(p) || "__none__";
|
||||
const ek = profileEndpointKey(p);
|
||||
if (!byCountry[iso]) byCountry[iso] = {};
|
||||
(byCountry[iso][ek] = byCountry[iso][ek] || []).push(p);
|
||||
});
|
||||
Object.keys(groups).forEach((key) => {
|
||||
const list = groups[key].slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
||||
// «Оптимальный» — сервер с лучшим измеренным пингом в стране, иначе первый по имени.
|
||||
let bestIdx = 0, bestMsVal = Infinity;
|
||||
list.forEach((p, i) => {
|
||||
const pr = pingMap[p.name];
|
||||
if (pr && pr.ok && pr.ms < bestMsVal) { bestMsVal = pr.ms; bestIdx = i; }
|
||||
Object.keys(byCountry).forEach((iso) => {
|
||||
const hostMap = byCountry[iso];
|
||||
const hostGroups = Object.keys(hostMap).map((ek) => {
|
||||
const list = hostMap[ek].slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
||||
let bestMs = Infinity;
|
||||
list.forEach((p) => {
|
||||
const pr = pingMap[p.name];
|
||||
if (pr && pr.ok && pr.ms < bestMs) bestMs = pr.ms;
|
||||
});
|
||||
return { ek: ek, list: list, bestMs: bestMs };
|
||||
});
|
||||
const region = key === "__none__" ? "" : countryRu(key);
|
||||
let n = 0;
|
||||
list.forEach((p, i) => {
|
||||
n += 1;
|
||||
// Стабильная нумерация по имени; «оптимальный» — лучший пинг среди РАЗНЫХ хостов.
|
||||
hostGroups.sort((a, b) => String(a.list[0].name).localeCompare(String(b.list[0].name)));
|
||||
let bestIdx = 0, bestMsVal = Infinity;
|
||||
hostGroups.forEach((g, i) => {
|
||||
if (g.bestMs < bestMsVal) { bestMsVal = g.bestMs; bestIdx = i; }
|
||||
});
|
||||
const region = iso === "__none__" ? "" : countryRu(iso);
|
||||
hostGroups.forEach((g, i) => {
|
||||
const n = i + 1;
|
||||
let title;
|
||||
if (i === bestIdx) {
|
||||
title = region ? (region + " — оптимальный сервер") : ("Сервер " + n + " — оптимальный");
|
||||
} else {
|
||||
title = region ? (region + " — сервер " + n) : ("Сервер " + n);
|
||||
}
|
||||
friendlyMap[p.name] = { title: title, region: region, iso: key === "__none__" ? "" : key };
|
||||
const protos = uniqueProtos(g.list);
|
||||
const groupNames = g.list.map((x) => x.name);
|
||||
g.list.forEach((p) => {
|
||||
friendlyMap[p.name] = {
|
||||
title: title,
|
||||
region: region,
|
||||
iso: iso === "__none__" ? "" : iso,
|
||||
endpointKey: g.ek,
|
||||
protocols: protos,
|
||||
groupNames: groupNames
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2341,16 +2501,38 @@
|
||||
renderCurServer(want);
|
||||
}
|
||||
|
||||
function orderedProfiles() {
|
||||
const list = profiles.slice();
|
||||
list.sort((a, b) => {
|
||||
const pa = pingMap[a.name], pb = pingMap[b.name];
|
||||
// Группы «один хост = один сервер» для списка; сортировка по лучшему пингу группы.
|
||||
function orderedServerGroups() {
|
||||
const seen = {};
|
||||
const groups = [];
|
||||
profiles.forEach((p) => {
|
||||
const f = friendlyMap[p.name];
|
||||
const ek = (f && f.endpointKey) || profileEndpointKey(p);
|
||||
const iso = (f && f.iso) || profileISO(p) || "__none__";
|
||||
const id = iso + "|" + ek;
|
||||
if (seen[id]) return;
|
||||
seen[id] = true;
|
||||
const names = (f && f.groupNames) || [p.name];
|
||||
const members = names.map((n) => profiles.find((x) => x.name === n)).filter(Boolean);
|
||||
if (!members.length) return;
|
||||
groups.push({
|
||||
id: id,
|
||||
endpointKey: ek,
|
||||
iso: iso === "__none__" ? "" : iso,
|
||||
members: members,
|
||||
title: friendlyTitle(members[0].name),
|
||||
protocols: (f && f.protocols) || uniqueProtos(members),
|
||||
ping: groupBestPing(members)
|
||||
});
|
||||
});
|
||||
groups.sort((a, b) => {
|
||||
const pa = a.ping, pb = b.ping;
|
||||
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 String(a.title).localeCompare(String(b.title));
|
||||
});
|
||||
return list;
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderCurServer(activeName) {
|
||||
@@ -2367,8 +2549,12 @@
|
||||
setFlagEl($("curFlag"), p);
|
||||
$("curName").textContent = friendlyTitle(p.name);
|
||||
$("curHost").textContent = "нажмите, чтобы выбрать другой сервер";
|
||||
$("curProto").textContent = (p.protocol || "?").toString();
|
||||
const pr = pingMap[p.name];
|
||||
$("curProto").textContent = protoPretty(p.protocol);
|
||||
const f = friendlyMap[p.name];
|
||||
const members = (f && f.groupNames)
|
||||
? f.groupNames.map((n) => profiles.find((x) => x.name === n)).filter(Boolean)
|
||||
: [p];
|
||||
const pr = groupBestPing(members) || pingMap[p.name];
|
||||
const el = $("curPing");
|
||||
if (pr && pr.ok) {
|
||||
el.className = "ms " + msClass(pr.ms, true);
|
||||
@@ -2389,8 +2575,8 @@
|
||||
function renderServerList(activeName) {
|
||||
const active = activeName || profile.value;
|
||||
serverList.innerHTML = "";
|
||||
const list = orderedProfiles();
|
||||
// Количество серверов — только здесь, мелкой приглушённой подписью.
|
||||
const list = orderedServerGroups();
|
||||
// Количество серверов — только здесь, мелкой приглушённой подписью (группы, не профили).
|
||||
const srvCount = $("srvCount");
|
||||
if (srvCount) {
|
||||
srvCount.textContent = list.length
|
||||
@@ -2404,25 +2590,26 @@
|
||||
serverList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
list.forEach((p) => {
|
||||
const isActive = p.name === active;
|
||||
list.forEach((g) => {
|
||||
const selected = pickGroupProfile(g.members, active);
|
||||
const isActive = g.members.some((m) => m.name === active);
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "server-row" + (isActive ? " active" : "");
|
||||
row.dataset.name = p.name;
|
||||
row.dataset.name = selected ? selected.name : g.members[0].name;
|
||||
|
||||
const left = document.createElement("div");
|
||||
left.className = "left";
|
||||
const flag = document.createElement("span");
|
||||
flag.className = "flag";
|
||||
setFlagEl(flag, p);
|
||||
setFlagEl(flag, selected || g.members[0]);
|
||||
const metaCol = document.createElement("div");
|
||||
metaCol.className = "meta-col";
|
||||
const nameRow = document.createElement("div");
|
||||
nameRow.className = "name-row";
|
||||
const nameEl = document.createElement("div");
|
||||
nameEl.className = "name";
|
||||
nameEl.textContent = friendlyTitle(p.name);
|
||||
nameEl.textContent = g.title;
|
||||
nameRow.appendChild(nameEl);
|
||||
if (isActive) {
|
||||
const badge = document.createElement("span");
|
||||
@@ -2431,14 +2618,43 @@
|
||||
nameRow.appendChild(badge);
|
||||
}
|
||||
metaCol.appendChild(nameRow);
|
||||
if (g.protocols && g.protocols.length) {
|
||||
const protos = document.createElement("div");
|
||||
protos.className = "protos";
|
||||
g.protocols.forEach((pr) => {
|
||||
// span, не button — строка сервера уже <button>, вложенные button недопустимы.
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "proto-tag" + (isActive && active === pr.name ? " selected" : "");
|
||||
tag.textContent = pr.label;
|
||||
tag.title = "Протокол " + pr.label;
|
||||
tag.setAttribute("role", "button");
|
||||
tag.tabIndex = 0;
|
||||
tag.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onServerClick(pr.name);
|
||||
});
|
||||
tag.addEventListener("dblclick", (e) => {
|
||||
e.stopPropagation();
|
||||
onServerConnect(pr.name);
|
||||
});
|
||||
tag.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onServerClick(pr.name);
|
||||
}
|
||||
});
|
||||
protos.appendChild(tag);
|
||||
});
|
||||
metaCol.appendChild(protos);
|
||||
}
|
||||
left.appendChild(flag);
|
||||
left.appendChild(metaCol);
|
||||
|
||||
const right = document.createElement("div");
|
||||
right.className = "right";
|
||||
// Протокол — только на карточке активного сервера / в «Свойствах», не в строках списка.
|
||||
const ms = document.createElement("span");
|
||||
const pr = pingMap[p.name];
|
||||
const pr = g.ping;
|
||||
if (pr && pr.ok) {
|
||||
ms.className = "ms " + msClass(pr.ms, true);
|
||||
ms.textContent = pr.ms + " ms";
|
||||
@@ -2452,7 +2668,7 @@
|
||||
ms.title = "пинг не измерен";
|
||||
}
|
||||
right.appendChild(ms);
|
||||
// [📊] — проверить пинг только этого сервера.
|
||||
// [📊] — пинг всех протоколов группы (лучший показывается в строке).
|
||||
const pingOne = document.createElement("button");
|
||||
pingOne.type = "button";
|
||||
pingOne.className = "row-ping";
|
||||
@@ -2460,27 +2676,30 @@
|
||||
pingOne.title = "Проверить пинг этого сервера";
|
||||
pingOne.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
runPingOne(p.name, pingOne, ms);
|
||||
runPingGroup(g.members, pingOne, ms);
|
||||
});
|
||||
right.appendChild(pingOne);
|
||||
|
||||
row.appendChild(left);
|
||||
row.appendChild(right);
|
||||
row.addEventListener("click", () => onServerClick(p.name));
|
||||
row.addEventListener("dblclick", () => onServerConnect(p.name));
|
||||
const rowName = selected ? selected.name : g.members[0].name;
|
||||
row.addEventListener("click", () => onServerClick(rowName));
|
||||
row.addEventListener("dblclick", () => onServerConnect(rowName));
|
||||
serverList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Пинг одного сервера по кнопке [📊] в строке.
|
||||
async function runPingOne(name, btnEl, msEl) {
|
||||
// Пинг всех профилей группы (один хост, разные протоколы) по кнопке [📊].
|
||||
async function runPingGroup(members, btnEl, msEl) {
|
||||
if (btnEl.disabled) return;
|
||||
btnEl.disabled = true;
|
||||
const prevText = msEl.textContent;
|
||||
msEl.textContent = "…";
|
||||
try {
|
||||
const res = await pingServer(name);
|
||||
if (res) pingMap[name] = res;
|
||||
for (const m of members || []) {
|
||||
const res = await pingServer(m.name);
|
||||
if (res) pingMap[m.name] = res;
|
||||
}
|
||||
rebuildFriendlyNames();
|
||||
renderServerList(profile.value);
|
||||
renderCurServer(profile.value);
|
||||
@@ -2845,8 +3064,7 @@
|
||||
else pingEl.textContent = "не измерен";
|
||||
}
|
||||
if (protoEl) {
|
||||
const proto = String(p.protocol || "—");
|
||||
protoEl.textContent = proto.charAt(0).toUpperCase() + proto.slice(1);
|
||||
protoEl.textContent = p.protocol ? protoPretty(p.protocol) : "—";
|
||||
}
|
||||
if (stEl) stEl.textContent = connected ? "🟢 Активен" : "⚪ Не активен";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user