v2.5.0: add shared 443 and per-user traffic

This commit is contained in:
Виталий Литвинов
2026-04-25 14:07:47 +03:00
parent c1b5ffc5a7
commit 63b564f70f
12 changed files with 990 additions and 34 deletions

View File

@@ -56,6 +56,9 @@ const i18n = {
tableUser: "User",
tableSecret: "Secret",
tableLink: "Link",
tableTraffic: "Traffic",
tableTrafficDelta: "Traffic delta",
tableTrafficTotal: "Total",
tableActions: "Actions",
userPlaceholder: "client-name",
addKey: "Add key",
@@ -81,6 +84,15 @@ const i18n = {
noHistory: "No traffic history yet",
noTrafficForRange: "No data for this range yet",
noRuntime: "Runtime data is not available",
userTrafficEyebrow: "Per user",
userTrafficTitle: "User traffic",
selectUserTraffic: "Select a key to see its traffic history",
openStats: "Stats",
trafficTotal: "Total",
currentConnections: "Connections",
activeIps: "Active IPs",
recentIps: "Recent IPs",
trafficRuntimeUnavailable: "Runtime unavailable",
badConnections: "Bad connections",
connections: "Connections",
uptime: "Uptime",
@@ -150,6 +162,7 @@ const i18n = {
port443NoRoutes: "No routed services detected",
port443Via: "via {value}",
roleMtproxy: "MTProxy",
roleEdge: "443 Edge",
roleSite: "Website",
roleXray: "Xray / 3x-ui",
roleAmneziawg: "AmneziaWG",
@@ -243,6 +256,9 @@ const i18n = {
tableUser: "Пользователь",
tableSecret: "Секрет",
tableLink: "Ссылка",
tableTraffic: "Трафик",
tableTrafficDelta: "Прирост трафика",
tableTrafficTotal: "Всего",
tableActions: "Действия",
userPlaceholder: "client-name",
addKey: "Добавить ключ",
@@ -268,6 +284,15 @@ const i18n = {
noHistory: "Истории трафика пока нет",
noTrafficForRange: "За этот период данных пока нет",
noRuntime: "Данные среды выполнения недоступны",
userTrafficEyebrow: "По пользователю",
userTrafficTitle: "Трафик ключа",
selectUserTraffic: "Выберите ключ, чтобы увидеть историю трафика",
openStats: "Статистика",
trafficTotal: "Всего",
currentConnections: "Подключения",
activeIps: "Активные IP",
recentIps: "Недавние IP",
trafficRuntimeUnavailable: "Runtime недоступен",
badConnections: "Ошибочные подключения",
connections: "Подключения",
uptime: "Аптайм",
@@ -337,6 +362,7 @@ const i18n = {
port443NoRoutes: "Маршрутизируемых сервисов не найдено",
port443Via: "через {value}",
roleMtproxy: "MTProxy",
roleEdge: "443 Edge",
roleSite: "Сайт",
roleXray: "Xray / 3x-ui",
roleAmneziawg: "AmneziaWG",
@@ -389,6 +415,11 @@ const state = {
trafficRange: "1h",
trafficView: "chart",
trafficLoading: false,
userTrafficUser: "",
userTrafficRange: "1h",
userTrafficView: "chart",
userTraffic: null,
userTrafficLoading: false,
pendingUsers: new Set(),
};
@@ -482,6 +513,7 @@ function applyI18n() {
$("#visualTitle").textContent = t("visualTitle");
$("#visualText").textContent = t("visualText");
updateTrafficControls();
updateUserTrafficControls();
updatePageTitle();
}
@@ -491,6 +523,7 @@ function setTheme(theme) {
localStorage.setItem("gotelegram-theme", state.theme);
applyI18n();
if (state.overview) renderStats();
if (state.userTraffic) renderUserTraffic();
}
async function setLanguage(lang) {
@@ -525,6 +558,10 @@ function setPage(page, push = true) {
}
if (next === "traffic") {
refreshStats().catch((err) => toast(err.message));
} else if (next === "keys") {
ensureUserTrafficSelection();
renderUserTraffic();
if (state.userTrafficUser) refreshUserTraffic().catch((err) => toast(err.message));
}
}
@@ -736,6 +773,15 @@ function updateTrafficControls() {
});
}
function updateUserTrafficControls() {
$$("[data-user-traffic-range]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.userTrafficRange === state.userTrafficRange);
});
$$("[data-user-traffic-view]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.userTrafficView === state.userTrafficView);
});
}
function trafficRangeLabel(range) {
const labels = {
"15m": t("range15m"),
@@ -886,14 +932,150 @@ function renderHistoryTable(rows) {
`).join("");
}
function ensureUserTrafficSelection() {
if (state.userTrafficUser && state.users.some((user) => user.name === state.userTrafficUser)) return;
state.userTrafficUser = state.users[0]?.name || "";
}
function userTrafficRows() {
return state.userTraffic?.history || [];
}
function bucketUserTrafficRows(rows) {
const filtered = filterTrafficRows(rows, state.userTrafficRange);
if (filtered.length <= 140) return filtered;
const chunk = Math.ceil(filtered.length / 120);
const buckets = [];
for (let i = 0; i < filtered.length; i += chunk) {
const slice = filtered.slice(i, i + chunk);
const last = slice[slice.length - 1];
buckets.push({
epoch: last.epoch,
total_delta: slice.reduce((sum, item) => sum + (Number(item.total_delta) || 0), 0),
total_octets: last.total_octets,
current_connections: last.current_connections,
active_unique_ips: last.active_unique_ips,
});
}
return buckets;
}
function fallbackUserTrafficSummaries(rows) {
return trafficRanges.map((range) => {
const windowRows = filterTrafficRows(rows, range);
if (!windowRows.length) {
return { range, points: 0, total_delta: 0, total_octets: 0 };
}
const last = windowRows[windowRows.length - 1];
return {
range,
points: windowRows.length,
total_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.total_delta) || 0), 0),
total_octets: Number(last.total_octets) || 0,
};
});
}
function renderUserTrafficLoading() {
$("#userTrafficChart").classList.toggle("is-hidden", state.userTrafficView !== "chart");
$("#userTrafficTableWrap").classList.toggle("is-hidden", state.userTrafficView !== "table");
$("#userTrafficChart").innerHTML = `<div class="empty-chart"><strong>${escapeHtml(t("loading"))}</strong></div>`;
$("#userTrafficTable").innerHTML = `<tr><td colspan="3" class="empty-cell">${escapeHtml(t("loading"))}</td></tr>`;
}
function drawUserTrafficChart(rows) {
const el = $("#userTrafficChart");
const points = bucketUserTrafficRows(rows);
const color = getComputedStyle(document.documentElement).getPropertyValue("--blue").trim() || "#2563eb";
if (points.length < 2) {
el.innerHTML = `<div class="empty-chart">
<strong>${escapeHtml(state.userTrafficUser ? t("noTrafficForRange") : t("selectUserTraffic"))}</strong>
<span>${escapeHtml(state.userTraffic?.status?.runtime_ok ? t("statsOk") : t("trafficRuntimeUnavailable"))}</span>
</div>`;
return;
}
const width = 900;
const height = 260;
const pad = { l: 54, r: 22, t: 24, b: 42 };
const max = Math.max(1, ...points.map((p) => Number(p.total_delta) || 0));
const plotW = width - pad.l - pad.r;
const plotH = height - pad.t - pad.b;
const toX = (i) => pad.l + (plotW * i) / Math.max(1, points.length - 1);
const toY = (v) => pad.t + plotH - ((v || 0) / max) * plotH;
const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${toX(i).toFixed(1)},${toY(p.total_delta).toFixed(1)}`).join(" ");
const grid = Array.from({ length: 5 }, (_, i) => {
const y = pad.t + (plotH / 4) * i;
return `<line x1="${pad.l}" y1="${y}" x2="${width - pad.r}" y2="${y}"></line>`;
}).join("");
const axis = t("chartMax").replace("{value}", fmtBytes(max));
el.innerHTML = `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeAttr(t("ariaTrafficHistory"))}">
<g class="grid">${grid}</g>
<path class="area proxy-area" d="${path} L${width - pad.r},${height - pad.b} L${pad.l},${height - pad.b} Z"></path>
<path class="line proxy-line" d="${path}"></path>
<text x="${pad.l}" y="17" class="axis">${escapeHtml(axis)}</text>
<text x="${pad.l}" y="${height - 12}" class="legend" fill="${color}">${escapeHtml(state.userTrafficUser || t("users"))}</text>
</svg>`;
}
function renderUserTrafficTable(rows) {
if (!rows.length) {
$("#userTrafficTable").innerHTML = `<tr><td colspan="3" class="empty-cell">${escapeHtml(t("noHistory"))}</td></tr>`;
return;
}
$("#userTrafficTable").innerHTML = rows.map((row) => `
<tr>
<td data-label="${escapeAttr(t("tablePeriod"))}"><strong>${escapeHtml(trafficRangeLabel(row.range))}</strong><small>${escapeHtml(row.points ? `${row.points} ${t("historyRows").toLowerCase()}` : t("noTrafficForRange"))}</small></td>
<td data-label="${escapeAttr(t("tableTrafficDelta"))}">${escapeHtml(fmtBytes(row.total_delta))}</td>
<td data-label="${escapeAttr(t("tableTrafficTotal"))}">${escapeHtml(fmtBytes(row.total_octets))}</td>
</tr>
`).join("");
}
function renderUserTraffic() {
updateUserTrafficControls();
if (!state.userTrafficUser) {
$("#userTrafficTitle").textContent = t("userTrafficTitle");
$("#userTrafficHealth").className = "status-pill health-unknown";
$("#userTrafficHealth").textContent = "--";
$("#userTrafficTotal").textContent = "--";
$("#userTrafficConnections").textContent = "--";
$("#userTrafficIps").textContent = "--";
$("#userTrafficChart").innerHTML = `<div class="empty-chart"><strong>${escapeHtml(t("selectUserTraffic"))}</strong></div>`;
$("#userTrafficTable").innerHTML = `<tr><td colspan="3" class="empty-cell">${escapeHtml(t("selectUserTraffic"))}</td></tr>`;
return;
}
$("#userTrafficTitle").textContent = `${t("userTrafficTitle")}: ${state.userTrafficUser}`;
if (state.userTrafficLoading) {
renderUserTrafficLoading();
return;
}
const payload = state.userTraffic || {};
const current = payload.current || {};
const rows = userTrafficRows();
const last = rows[rows.length - 1] || {};
const total = Number(current.total_octets) || Number(last.total_octets) || 0;
$("#userTrafficHealth").className = `status-pill ${current.enabled === false ? "health-stopped" : (current.ok ? "health-ok" : "health-stale")}`;
$("#userTrafficHealth").textContent = current.enabled === false ? t("disabled") : (current.ok ? t("healthOk") : t("trafficRuntimeUnavailable"));
$("#userTrafficTotal").textContent = fmtBytes(total);
$("#userTrafficConnections").textContent = current.current_connections ?? last.current_connections ?? 0;
$("#userTrafficIps").textContent = current.active_unique_ips ?? last.active_unique_ips ?? 0;
$("#userTrafficChart").classList.toggle("is-hidden", state.userTrafficView !== "chart");
$("#userTrafficTableWrap").classList.toggle("is-hidden", state.userTrafficView !== "table");
drawUserTrafficChart(rows);
renderUserTrafficTable(payload.summary_rows?.length ? payload.summary_rows : fallbackUserTrafficSummaries(rows));
}
function renderUsers() {
const tbody = $("#usersTable");
if (!state.users.length) {
tbody.innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="6" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
return;
}
tbody.innerHTML = state.users.map((user) => {
const pending = state.pendingUsers.has(user.name);
const traffic = user.traffic || {};
const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--";
const activeIps = Number(traffic.active_unique_ips) || 0;
return `
<tr class="${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""}">
<td data-label="${escapeAttr(t("tableUser"))}">
@@ -910,6 +1092,13 @@ function renderUsers() {
</td>
<td data-label="${escapeAttr(t("tableSecret"))}"><code title="${escapeAttr(user.secret)}">${escapeHtml(user.secret)}</code></td>
<td data-label="${escapeAttr(t("tableLink"))}"><button class="soft" data-copy="${escapeAttr(user.link)}" ${user.enabled ? "" : "disabled"}>${escapeHtml(t("copyLink"))}</button></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>
</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>
@@ -993,6 +1182,9 @@ async function refreshAll() {
renderUsers();
if (state.page === "traffic") {
await refreshStats();
} else if (state.page === "keys") {
ensureUserTrafficSelection();
await refreshUserTraffic();
}
} catch (err) {
toast(err.message);
@@ -1021,6 +1213,26 @@ async function refreshStats(options = {}) {
}
}
async function refreshUserTraffic(options = {}) {
ensureUserTrafficSelection();
if (!state.userTrafficUser) {
renderUserTraffic();
return null;
}
if (options.showLoading) {
state.userTrafficLoading = true;
renderUserTraffic();
}
try {
const data = await api(`/api/users/${encodeURIComponent(state.userTrafficUser)}/traffic?range=${encodeURIComponent(state.userTrafficRange)}`);
state.userTraffic = data;
return data;
} finally {
state.userTrafficLoading = false;
renderUserTraffic();
}
}
async function changeTrafficRange(range) {
const next = trafficRanges.includes(range) ? range : "1h";
if (next === state.trafficRange && state.stats?.range === next) return;
@@ -1036,6 +1248,21 @@ async function changeTrafficRange(range) {
}
}
async function changeUserTrafficRange(range) {
const next = trafficRanges.includes(range) ? range : "1h";
if (next === state.userTrafficRange && state.userTraffic?.range === next) return;
const previous = state.userTrafficRange;
state.userTrafficRange = next;
try {
await refreshUserTraffic({ showLoading: true });
} catch (err) {
state.userTrafficRange = previous;
state.userTrafficLoading = false;
renderUserTraffic();
toast(err.message);
}
}
async function addUser(name) {
const data = await api("/api/users", {
method: "POST",
@@ -1206,6 +1433,14 @@ document.addEventListener("click", async (eventObj) => {
} else if (button.dataset.trafficView) {
state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart";
renderStats();
} else if (button.dataset.userTraffic) {
state.userTrafficUser = button.dataset.userTraffic;
refreshUserTraffic({ showLoading: true }).catch((err) => toast(err.message));
} else if (button.dataset.userTrafficRange) {
changeUserTrafficRange(button.dataset.userTrafficRange);
} else if (button.dataset.userTrafficView) {
state.userTrafficView = button.dataset.userTrafficView === "table" ? "table" : "chart";
renderUserTraffic();
} else if (button.dataset.copy) {
await copyText(button.dataset.copy);
} else if (button.dataset.delete) {

View File

@@ -11,7 +11,7 @@
document.documentElement.dataset.theme = theme;
}());
</script>
<link rel="stylesheet" href="/styles.css?v=2.5.0-admin7">
<link rel="stylesheet" href="/styles.css?v=2.5.0-admin8">
</head>
<body>
<div class="app-shell">
@@ -202,6 +202,7 @@
<th data-i18n="tableStatus">Status</th>
<th data-i18n="tableSecret">Secret</th>
<th data-i18n="tableLink">Link</th>
<th data-i18n="tableTraffic">Traffic</th>
<th data-i18n="tableActions">Actions</th>
</tr>
</thead>
@@ -209,6 +210,56 @@
</table>
</div>
</div>
<div class="panel user-traffic-panel" id="userTrafficPanel">
<div class="panel-head">
<div>
<p class="eyebrow" data-i18n="userTrafficEyebrow">Per user</p>
<h2 id="userTrafficTitle" data-i18n="userTrafficTitle">User traffic</h2>
</div>
<div class="panel-actions">
<span id="userTrafficHealth" class="status-pill">--</span>
</div>
</div>
<div class="traffic-summary compact">
<article>
<span data-i18n="trafficTotal">Total</span>
<strong id="userTrafficTotal">--</strong>
</article>
<article>
<span data-i18n="currentConnections">Connections</span>
<strong id="userTrafficConnections">--</strong>
</article>
<article>
<span data-i18n="activeIps">Active IPs</span>
<strong id="userTrafficIps">--</strong>
</article>
</div>
<div class="traffic-controls">
<div class="segmented" id="userTrafficRange" aria-label="User traffic range" data-i18n-aria-label="ariaTrafficRange">
<button type="button" data-user-traffic-range="15m" data-i18n="range15m">15 min</button>
<button type="button" data-user-traffic-range="1h" data-i18n="range1h">1 hour</button>
<button type="button" data-user-traffic-range="24h" data-i18n="range24h">24 hours</button>
<button type="button" data-user-traffic-range="month" data-i18n="rangeMonth">Month</button>
</div>
<div class="segmented" id="userTrafficView" aria-label="User traffic view" data-i18n-aria-label="ariaTrafficView">
<button type="button" data-user-traffic-view="chart" data-i18n="viewChart">Chart</button>
<button type="button" data-user-traffic-view="table" data-i18n="viewRows">Rows</button>
</div>
</div>
<div id="userTrafficChart" class="traffic-chart"></div>
<div class="table-wrap" id="userTrafficTableWrap">
<table>
<thead>
<tr>
<th data-i18n="tablePeriod">Period</th>
<th data-i18n="tableTrafficDelta">Traffic delta</th>
<th data-i18n="tableTrafficTotal">Total</th>
</tr>
</thead>
<tbody id="userTrafficTable"></tbody>
</table>
</div>
</div>
</section>
<section class="page-panel" data-page="backups">
@@ -321,6 +372,6 @@
</div>
</div>
</div>
<script src="/app.js?v=2.5.0-admin7" type="module"></script>
<script src="/app.js?v=2.5.0-admin8" type="module"></script>
</body>
</html>

View File

@@ -667,6 +667,10 @@ h2 {
margin-bottom: 14px;
}
.traffic-summary.compact {
grid-template-columns: repeat(3, minmax(140px, 1fr));
}
.health-ok { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
.health-error { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); }
.health-stale,
@@ -830,6 +834,24 @@ td small {
gap: 8px;
}
.traffic-cell {
display: grid;
gap: 6px;
min-width: 150px;
}
.traffic-cell strong {
font-size: 14px;
}
.traffic-cell .soft {
width: max-content;
}
.user-traffic-panel {
margin-top: 18px;
}
.status-control {
display: inline-flex;
align-items: center;