Release 3.9.0+2: tolerant subscription import, full Remnawave server list, subscription info card (expiry / traffic / devices).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-08-01 17:01:37 +03:00
co-authored by Cursor
parent 015ded9bd5
commit e34312ef9c
18 changed files with 818 additions and 174 deletions
+120 -4
View File
@@ -507,6 +507,35 @@
font-weight: 600;
font-size: .9rem;
}
.subinfo {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--row-bg);
padding: 11px 13px;
margin-bottom: 12px;
display: grid;
gap: 7px;
}
.subinfo-title {
font-family: Outfit, sans-serif;
font-size: .72rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--muted);
}
.subinfo-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: .84rem;
}
.subinfo-row .k { color: var(--muted); flex: 0 0 auto; }
.subinfo-row .v { font-weight: 700; text-align: right; min-width: 0; }
.subinfo-row .v.warn { color: #dc2626; }
[data-theme="dark"] .subinfo-row .v.warn { color: #f87171; }
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
@@ -1002,6 +1031,22 @@
</details>
</section>
<section class="subinfo" id="subInfoCard" hidden>
<div class="subinfo-title">Подписка</div>
<div class="subinfo-row" id="subExpireRow" hidden>
<span class="k">Окончание подписки</span>
<span class="v" id="subExpireVal"></span>
</div>
<div class="subinfo-row" id="subTrafficRow" hidden>
<span class="k">Трафик</span>
<span class="v" id="subTrafficVal"></span>
</div>
<div class="subinfo-row" id="subDevicesRow" hidden>
<span class="k">Устройства</span>
<span class="v" id="subDevicesVal"></span>
</div>
</section>
<div class="row">
<span>Системный прокси</span>
<label class="switch">
@@ -1444,6 +1489,67 @@
});
}
function fmtTraffic(bytes) {
if (!bytes || bytes <= 0) return "0 ГБ";
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1024) {
const tb = gb / 1024;
return (tb >= 100 ? Math.round(tb) : tb.toFixed(1)) + " ТБ";
}
if (gb >= 100) return Math.round(gb) + " ГБ";
if (gb >= 1) return gb.toFixed(1) + " ГБ";
const mb = bytes / (1024 * 1024);
return (mb >= 1 ? Math.round(mb) : 1) + " МБ";
}
function renderSubInfo(info) {
const card = $("subInfoCard");
if (!card) return;
const expireRow = $("subExpireRow"), trafficRow = $("subTrafficRow"), devicesRow = $("subDevicesRow");
const hasExpire = !!(info && info.expire);
const hasTraffic = !!(info && (info.total || info.download || info.upload));
const hasDevices = !!(info && info.devices_known);
if (!info || (!hasExpire && !hasTraffic && !hasDevices)) {
card.hidden = true;
return;
}
card.hidden = false;
expireRow.hidden = !hasExpire;
if (hasExpire) {
const d = new Date(info.expire * 1000);
let text = "";
try {
text = d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
} catch (_) {
text = d.toISOString().slice(0, 10);
}
const daysLeft = Math.floor((d.getTime() - Date.now()) / 86400000);
const v = $("subExpireVal");
if (daysLeft < 0) {
v.textContent = text + " · истекла";
v.classList.add("warn");
} else {
v.textContent = daysLeft <= 14 ? (text + " · осталось " + daysLeft + " дн.") : text;
v.classList.toggle("warn", daysLeft <= 3);
}
}
trafficRow.hidden = !hasTraffic;
if (hasTraffic) {
const used = (info.upload || 0) + (info.download || 0);
$("subTrafficVal").textContent = info.total > 0
? (fmtTraffic(used) + " из " + fmtTraffic(info.total))
: (fmtTraffic(used) + " · Безлимит");
}
devicesRow.hidden = !hasDevices;
if (hasDevices) {
const n = info.devices || 0;
$("subDevicesVal").textContent = info.device_limit > 0 ? (n + " из " + info.device_limit) : String(n);
}
}
function renderUpdate(u, version) {
const label = "Navis v" + (version || "?");
verLabel.textContent = label;
@@ -1517,6 +1623,7 @@
rememberPings(state.pings || []);
fillProfiles(state.profiles || [], state.active_profile || state.profile);
renderUpdate(state.update, state.version);
renderSubInfo(state.sub_info);
if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url;
}
@@ -1699,13 +1806,22 @@
return;
}
setMeta("Загрузка подписки…");
const n = await importSubscription(url);
const r = await importSubscription(url);
formHydrated = false;
dirty = false;
setMeta("Импортировано: " + n + " · измеряю пинг…", "ok");
setMeta(importSummary(r) + " · измеряю пинг…", "ok");
await runBest(!!autoBest.checked);
}
function importSummary(r) {
if (r && typeof r === "object") {
let s = "Импортировано " + (r.imported || 0) + " серверов";
if (r.skipped > 0) s += " (пропущено " + r.skipped + ")";
return s;
}
return "Импортировано: " + r;
}
function readRemnawave() {
return {
base_url: (rwBase && rwBase.value || "").trim(),
@@ -1744,10 +1860,10 @@
return;
}
setMeta("Remnawave: загрузка конфигов…");
const n = await importRemnawave(s);
const r = await importRemnawave(s);
formHydrated = false;
dirty = false;
setMeta("Remnawave: импортировано " + n + " · пинг…", "ok");
setMeta("Remnawave: " + importSummary(r).toLowerCase() + " · пинг…", "ok");
await runBest(!!autoBest.checked);
} catch (e) { setMeta(String(e), "err"); }
}));