v2.5.0: align key controls and add IP limits

This commit is contained in:
Виталий Литвинов
2026-04-25 15:19:28 +03:00
parent bd3fc1af18
commit 507a2979e5
8 changed files with 583 additions and 33 deletions

View File

@@ -57,6 +57,9 @@ const i18n = {
tableSecret: "Secret",
tableLink: "Link",
tableTraffic: "Traffic",
ipLimit: "IP limit",
ipLimitHint: "0 = unlimited",
saveIpLimit: "OK",
tableTrafficDelta: "Traffic delta",
tableTrafficTotal: "Total",
tableActions: "Actions",
@@ -165,6 +168,7 @@ const i18n = {
languageSaved: "Language saved",
keyEnabled: "Key enabled",
keyDisabled: "Key disabled",
ipLimitSaved: "IP limit saved",
visualTitle: "Port 443 map",
visualText: "Shows the public 443 listener and services routed behind it, including the website on local nginx.",
port443Checked: "checked",
@@ -276,6 +280,9 @@ const i18n = {
tableSecret: "Секрет",
tableLink: "Ссылка",
tableTraffic: "Трафик",
ipLimit: "Лимит IP",
ipLimitHint: "0 = безлимит",
saveIpLimit: "OK",
tableTrafficDelta: "Прирост трафика",
tableTrafficTotal: "Всего",
tableActions: "Действия",
@@ -384,6 +391,7 @@ const i18n = {
languageSaved: "Язык сохранён",
keyEnabled: "Ключ включён",
keyDisabled: "Ключ отключён",
ipLimitSaved: "Лимит IP сохранён",
visualTitle: "Карта порта 443",
visualText: "Показывает публичного слушателя 443 и сервисы, которые живут за ним, включая сайт на локальном nginx.",
port443Checked: "проверено",
@@ -1138,6 +1146,7 @@ function renderUsers() {
const traffic = user.traffic || {};
const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--";
const activeIps = Number(traffic.active_unique_ips) || 0;
const maxUniqueIps = Number.isFinite(Number(user.max_unique_ips)) ? Math.max(0, Number(user.max_unique_ips)) : 0;
return `
<tr class="${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""} ${selected ? "selected-row" : ""}" data-select-user-traffic="${escapeAttr(user.name)}" aria-selected="${selected ? "true" : "false"}">
<td data-label="${escapeAttr(t("tableUser"))}">
@@ -1161,14 +1170,25 @@ function renderUsers() {
</td>
<td data-label="${escapeAttr(t("tableTraffic"))}">
<div class="traffic-cell">
<strong>${escapeHtml(trafficTotal)}</strong>
<small>${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))}</small>
<button class="soft" data-user-traffic="${escapeAttr(user.name)}">${escapeHtml(t("openStats"))}</button>
<div class="traffic-main">
<span>
<strong>${escapeHtml(trafficTotal)}</strong>
<small>${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))}</small>
</span>
<button class="soft" data-user-traffic="${escapeAttr(user.name)}">${escapeHtml(t("openStats"))}</button>
</div>
<form class="ip-limit-control" data-ip-limit-form="${escapeAttr(user.name)}" title="${escapeAttr(t("ipLimitHint"))}">
<span>${escapeHtml(t("ipLimit"))}</span>
<input type="number" min="0" max="1000000" step="1" value="${escapeAttr(maxUniqueIps)}" data-ip-limit-input="${escapeAttr(user.name)}" aria-label="${escapeAttr(t("ipLimit"))}: ${escapeAttr(user.name)}">
<button class="soft" type="submit" data-ip-limit-save="${escapeAttr(user.name)}">${escapeHtml(t("saveIpLimit"))}</button>
</form>
</div>
</td>
<td data-label="${escapeAttr(t("tableActions"))}" class="actions">
<button class="soft" data-copy="${escapeAttr(user.secret)}">${escapeHtml(t("copySecret"))}</button>
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button>
<td data-label="${escapeAttr(t("tableActions"))}">
<div class="action-buttons">
<button class="soft" data-copy="${escapeAttr(user.secret)}">${escapeHtml(t("copySecret"))}</button>
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button>
</div>
</td>
</tr>
`; }).join("");
@@ -1394,6 +1414,32 @@ async function setUserEnabled(name, enabled) {
}
}
async function setUserMaxUniqueIps(name, value) {
const limit = Number.parseInt(value, 10);
if (!Number.isFinite(limit) || limit < 0 || limit > 1000000) {
toast(t("ipLimitHint"));
return;
}
const form = $$("[data-ip-limit-form]").find((item) => item.dataset.ipLimitForm === name);
const controls = form ? Array.from(form.querySelectorAll("input, button")) : [];
controls.forEach((control) => { control.disabled = true; });
try {
const data = await api(`/api/users/${encodeURIComponent(name)}/max-ips`, {
method: "POST",
body: JSON.stringify({ max_unique_ips: limit }),
});
state.users = state.users.map((user) => user.name === name ? { ...user, max_unique_ips: data.max_unique_ips } : user);
renderUsers();
addEvent(t("ipLimitSaved"), `${name}: ${data.max_unique_ips}`);
toast(t("changesApplyInBackground"));
setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1400);
} catch (err) {
toast(err.message);
} finally {
controls.forEach((control) => { control.disabled = false; });
}
}
async function createBackup() {
const btn = $("#createBackupBtn");
btn.disabled = true;
@@ -1590,6 +1636,7 @@ document.addEventListener("click", async (eventObj) => {
return;
}
if (eventObj.target.closest("input, select, textarea, label, form")) return;
const row = eventObj.target.closest("[data-select-user-traffic]");
if (!row) return;
selectUserTraffic(row.dataset.selectUserTraffic, { scroll: true });
@@ -1618,6 +1665,14 @@ $("#addUserForm").addEventListener("submit", (eventObj) => {
addUser(name).catch((err) => toast(err.message));
});
document.addEventListener("submit", (eventObj) => {
const form = eventObj.target.closest("[data-ip-limit-form]");
if (!form) return;
eventObj.preventDefault();
const input = form.querySelector("[data-ip-limit-input]");
setUserMaxUniqueIps(form.dataset.ipLimitForm, input?.value || "0");
});
$("#refreshBtn").addEventListener("click", refreshAll);
$("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventObj.target.value));
$("#promoClose").addEventListener("click", () => {