Speed meter
This commit is contained in:
@@ -16,6 +16,8 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine
|
||||
- Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual
|
||||
- Cartões de status ao vivo nos espaços SSH, Xray e Infraestrutura, com confirmações integradas ao painel
|
||||
- Listas de usuários SSH e Xray com botões de ordenação e filtros por status, conexão, uso, validade e cota, além de cabeçalhos clicáveis
|
||||
- Velocidade ao vivo (subida/descida) por conta nas listas SSH e Xray, somando todas as conexões do usuário, com ordenação por velocidade
|
||||
- Listas de usuários em formato de cartão no celular: cada linha vira um cartão com rótulos, sem rolagem lateral
|
||||
- Navegação interna consistente com o Bot: SSH/SlowDNS e Revendedores separam consulta de cadastro; Xray separa Usuários, Criar usuário, Configuração e Logs; Configurações separa Rede/SSH, SlowDNS, UDP, TLS e Xray
|
||||
- Contas de revendedor (reseller) com cota de usuários e escopo próprio
|
||||
- Gerenciamento multi-servidor (master/slave) direto pelo painel
|
||||
@@ -557,6 +559,8 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post
|
||||
- Native shared XHTTP endpoint: VLESS **or** VMess on `/` and SSH on `/ssh`, using the same domain/port/TLS
|
||||
- Compact infrastructure workspace with Servers, Status, Monitoring, and Traffic in one visual switcher
|
||||
- Live status cards across SSH, Xray, and Infrastructure, with panel-native confirmations
|
||||
- Live per-account up/down speed in the SSH and Xray user lists, summed across every connection the account has open, sortable by speed
|
||||
- User lists collapse into labelled cards on phones, so there is no sideways scrolling
|
||||
- Bot-style section navigation throughout the panel: SSH/SlowDNS and Resellers separate lists from creation; Xray separates Users, Create User, Configuration, and Logs; Settings separates Network/SSH, SlowDNS, UDP, TLS, and Xray
|
||||
- Reseller accounts with a user quota and self-scoped access
|
||||
- Multi-server (master/slave) management directly from the panel
|
||||
@@ -1145,7 +1149,7 @@ curl -s "http://SERVER_IP:9090/api/users" -H "X-Session-Token: $TOKEN"
|
||||
|
||||
#### `GET /api/users` — session
|
||||
- Optional query: `server_id`. Resellers see only their own users; superadmins see all.
|
||||
- `200`: array of user objects: `username` (string), `active_conns` (int), `max_connections` (int), `expires_at` (string/null), `limit_mbps_up` (int), `limit_mbps_down` (int), `data_quota_bytes` (int64), `quota_action` (`block` or `throttle`), `quota_throttle_mbps` (int), `total_uplink_bytes`, `total_downlink_bytes`, `total_bytes`, `quota_exceeded`, `totp_secret` (string, omitempty), `totp_period` (int), `totp_window` (int), `totp_digits` (int), `allow_static_password` (bool), `totp_enabled` (bool), `owner_username` (string, omitempty), `server_id` (string, omitempty).
|
||||
- `200`: array of user objects: `username` (string), `active_conns` (int), `max_connections` (int), `expires_at` (string/null), `limit_mbps_up` (int), `limit_mbps_down` (int), `data_quota_bytes` (int64), `quota_action` (`block` or `throttle`), `quota_throttle_mbps` (int), `total_uplink_bytes`, `total_downlink_bytes`, `total_bytes`, `up_bytes_per_sec` (float, live account-wide upload speed), `down_bytes_per_sec` (float, live account-wide download speed), `quota_exceeded`, `totp_secret` (string, omitempty), `totp_period` (int), `totp_window` (int), `totp_digits` (int), `allow_static_password` (bool), `totp_enabled` (bool), `owner_username` (string, omitempty), `server_id` (string, omitempty).
|
||||
|
||||
#### `POST /api/users/create` — session
|
||||
Creates or updates (upsert) an SSH user.
|
||||
@@ -1265,7 +1269,7 @@ Read/write a managed server's `config.json`. Query: `server_id`. Local delegates
|
||||
#### `GET /api/xray/inbounds` — session
|
||||
- Optional `server_id`. Lists only inbounds that carry client lists (vless/vmess/trojan). Resellers see all inbounds but only their own clients. Clients are enriched with DB metadata and runtime stats.
|
||||
- `200`: array of `{ "tag": string, "protocol": string, "port": <raw>, "listen": string, "clients": [ XrayClientInfo ] }`.
|
||||
- **XrayClientInfo**: `id` (string, the UUID), `password` (string, omitempty), `email` (string), `level` (int), `online` (bool), `last_active` (string/null), `uplink_bytes` (int64), `downlink_bytes` (int64), `total_bytes` (int64), `active_connections` (int), `name` (string), `expires_at` (string/null), `expiration_days` (int; `-1` = no expiry, `0` = expired), `max_conns`, `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `quota_exceeded`, `owner_username`, `expired`.
|
||||
- **XrayClientInfo**: `id` (string, the UUID), `password` (string, omitempty), `email` (string), `level` (int), `online` (bool), `last_active` (string/null), `uplink_bytes` (int64), `downlink_bytes` (int64), `total_bytes` (int64), `up_bytes_per_sec` (float, live client-wide upload speed), `down_bytes_per_sec` (float, live client-wide download speed), `active_connections` (int), `name` (string), `expires_at` (string/null), `expiration_days` (int; `-1` = no expiry, `0` = expired), `max_conns`, `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `quota_exceeded`, `owner_username`, `expired`.
|
||||
|
||||
#### `POST /api/xray/clients/add` — session
|
||||
- Body: `inbound_tag` (string, required), `uuid` (valid UUID, required), `email` (string, optional — defaults to name then uuid), `name` (string, optional), `expires_at` (RFC3339, `YYYY-MM-DDThh:mm`, or `YYYY-MM-DD`), `max_connections` (0–10000), `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `owner_username` (string, optional — superadmin only), `server_id` (string, optional).
|
||||
|
||||
@@ -761,3 +761,44 @@ th[data-sort-key].sort-desc::after{content:" \25BC";}
|
||||
.user-list-controls{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;margin:0 0 16px;padding:12px;border:1px solid rgba(var(--section-accent,139,92,246),.18);border-radius:16px;background:rgba(var(--section-accent,139,92,246),.055)}
|
||||
.user-list-control-group{display:flex;flex-direction:column;gap:6px;min-width:0}.user-list-control-label{color:var(--muted);font-size:.63rem;font-weight:900;letter-spacing:.09em;text-transform:uppercase}.user-list-buttons{display:flex;align-items:center;gap:5px;flex-wrap:wrap}.user-list-filter-btn{min-height:30px;padding:5px 9px;border:1px solid rgba(148,163,184,.16);border-radius:10px;background:rgba(255,255,255,.025);color:var(--muted);font-size:.68rem;font-weight:850;cursor:pointer;transition:.15s ease}.user-list-filter-btn:hover{color:var(--text);border-color:rgba(var(--section-accent,139,92,246),.38);background:rgba(var(--section-accent,139,92,246),.09)}.user-list-filter-btn.active{color:#fff;border-color:rgba(var(--section-accent,139,92,246),.44);background:linear-gradient(135deg,rgba(var(--section-accent,139,92,246),.3),rgba(34,211,238,.1));box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}.user-list-filter-btn[data-direction]::after{margin-left:4px;font-size:.7em}.user-list-filter-btn[data-direction="asc"]::after{content:"\25B2"}.user-list-filter-btn[data-direction="desc"]::after{content:"\25BC"}.user-list-count{margin-left:auto;white-space:nowrap}
|
||||
@media(max-width:760px){.user-list-controls{align-items:stretch}.user-list-control-group{width:100%}.user-list-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}.user-list-filter-btn{width:100%}.user-list-count{margin-left:0;align-self:flex-start}}
|
||||
|
||||
/* Live per-account speed (whole account, all connections summed) */
|
||||
.speed-cell{display:inline-flex;flex-direction:column;gap:1px;line-height:1.25;font-variant-numeric:tabular-nums;font-weight:850;white-space:nowrap;}
|
||||
.speed-cell .speed-down{color:var(--accent-3);}
|
||||
.speed-cell .speed-up{color:var(--accent);}
|
||||
|
||||
/* Card tables: on phones the wide user lists stop scrolling sideways and each
|
||||
row becomes a labelled card. Labels come from each cell's data-label. */
|
||||
@media(max-width:760px){
|
||||
.tbl-wrap:has(table.table-cards){overflow:visible;border:0;border-radius:0;background:transparent;}
|
||||
table.table-cards{display:block;min-width:0;width:100%;font-size:.78rem;}
|
||||
table.table-cards thead{display:none;}
|
||||
table.table-cards tbody{display:flex;flex-direction:column;gap:10px;}
|
||||
table.table-cards tr{
|
||||
display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px;
|
||||
padding:13px 14px;border:1px solid rgba(148,163,184,.14);border-radius:18px;
|
||||
background:rgba(3,6,10,.55);
|
||||
}
|
||||
table.table-cards tbody tr:hover{background:rgba(34,211,238,.05);}
|
||||
table.table-cards td{
|
||||
display:flex;flex-direction:column;gap:3px;min-width:0;
|
||||
padding:0;border:0;font-size:.78rem!important;overflow-wrap:anywhere;
|
||||
}
|
||||
table.table-cards td::before{
|
||||
content:attr(data-label);color:var(--muted);font-size:.6rem;font-weight:900;
|
||||
letter-spacing:.1em;text-transform:uppercase;
|
||||
}
|
||||
table.table-cards td[colspan]{grid-column:1/-1;text-align:center;}
|
||||
table.table-cards td:not([data-label])::before{display:none;}
|
||||
table.table-cards td.cell-primary{grid-column:1/-1;font-size:.98rem!important;font-weight:900;color:var(--text);}
|
||||
table.table-cards td.cell-primary::before{display:none;}
|
||||
table.table-cards td.cell-wide{grid-column:1/-1;}
|
||||
table.table-cards td.cell-actions{
|
||||
grid-column:1/-1;flex-direction:row;flex-wrap:wrap;gap:6px;
|
||||
padding-top:4px;white-space:normal!important;
|
||||
}
|
||||
table.table-cards td.cell-actions::before{display:none;}
|
||||
table.table-cards td.cell-actions .btn{flex:1 1 auto;margin:0!important;min-height:36px;}
|
||||
table.table-cards .table-meter{max-width:none;}
|
||||
table.table-cards .speed-cell{flex-direction:row;gap:12px;}
|
||||
}
|
||||
|
||||
@@ -711,6 +711,34 @@ function clientTrafficHTML(c) {
|
||||
return `${escapeHTML(formatBytes(total))} / ${escapeHTML(quotaLabel)}${escapeHTML(state)}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`;
|
||||
}
|
||||
|
||||
// ─── Live bandwidth ───────────────────────────────────────────────────────────
|
||||
// The API reports the account's current speed in bytes per second, summed over
|
||||
// every connection it has open. Speeds are shown in bits per second because
|
||||
// that is the unit the per-user limits use.
|
||||
function formatSpeed(bytesPerSec) {
|
||||
const bits = Number(bytesPerSec || 0) * 8;
|
||||
if (!Number.isFinite(bits) || bits < 1000) return "0";
|
||||
if (bits < 1e6) return `${Math.round(bits / 1e3)} kbps`;
|
||||
if (bits < 1e9) return `${(bits / 1e6).toFixed(bits < 1e7 ? 2 : 1)} Mbps`;
|
||||
return `${(bits / 1e9).toFixed(2)} Gbps`;
|
||||
}
|
||||
|
||||
function isIdleSpeed(upBytesPerSec, downBytesPerSec) {
|
||||
return Number(upBytesPerSec || 0) * 8 < 1000 && Number(downBytesPerSec || 0) * 8 < 1000;
|
||||
}
|
||||
|
||||
function speedHTML(upBytesPerSec, downBytesPerSec) {
|
||||
if (isIdleSpeed(upBytesPerSec, downBytesPerSec)) return `<span class="hint">${t("idle")}</span>`;
|
||||
return `<span class="speed-cell">`
|
||||
+ `<span class="speed-down">↓ ${escapeHTML(formatSpeed(downBytesPerSec))}</span>`
|
||||
+ `<span class="speed-up">↑ ${escapeHTML(formatSpeed(upBytesPerSec))}</span>`
|
||||
+ `</span>`;
|
||||
}
|
||||
|
||||
function speedTotalBytesPerSec(entry) {
|
||||
return Number(entry?.up_bytes_per_sec || 0) + Number(entry?.down_bytes_per_sec || 0);
|
||||
}
|
||||
|
||||
function updateCell(row, name, html) {
|
||||
const cell = row?.querySelector?.(`[data-cell="${name}"]`);
|
||||
if (cell && cell.innerHTML !== html) cell.innerHTML = html;
|
||||
@@ -747,6 +775,7 @@ function patchRenderedInbounds(inbounds) {
|
||||
updateCell(row, "status", clientStatusHTML(c));
|
||||
updateCell(row, "online", clientOnlineHTML(c));
|
||||
updateCell(row, "connections", escapeHTML(c.active_connections || 0));
|
||||
updateCell(row, "speed", speedHTML(c.up_bytes_per_sec, c.down_bytes_per_sec));
|
||||
updateCell(row, "traffic", clientTrafficHTML(c));
|
||||
updateCell(row, "max", escapeHTML(c.max_conns || "∞"));
|
||||
}
|
||||
@@ -782,3 +811,13 @@ Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Apply safe defaults":"Aplicar padrões seguros",
|
||||
"XHTTP is handled as VPN tunnel traffic: packet requests and reassembly are limited only by bounded byte backpressure, never by a request count. Existing saved web-style caps are ignored automatically after update. Per-user max_conns, quota, and bandwidth policies still work normally.":"O XHTTP é tratado como tráfego de túnel VPN: requisições de pacotes e remontagem usam somente backpressure com limite de bytes, nunca limite por quantidade de requisições. Limites web antigos já salvos são ignorados automaticamente após a atualização. As regras por usuário de max_conns, cota e banda continuam funcionando normalmente."
|
||||
});
|
||||
|
||||
// Live per-account bandwidth column, shared by the SSH and Xray user lists.
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Speed":"Speed", "Limit up":"Limit up", "Limit down":"Limit down",
|
||||
"Current up/down speed of the whole account, across all of its connections.":"Current up/down speed of the whole account, across all of its connections.",
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Speed":"Velocidade", "Limit up":"Limite de envio", "Limit down":"Limite de download",
|
||||
"Current up/down speed of the whole account, across all of its connections.":"Velocidade atual de envio/recebimento da conta inteira, somando todas as conexões.",
|
||||
});
|
||||
|
||||
@@ -75,16 +75,17 @@ const USER_SORT_EXTRACT = {
|
||||
max: u => u.max_connections || 0,
|
||||
up: u => u.limit_mbps_up || 0,
|
||||
down: u => u.limit_mbps_down || 0,
|
||||
speed: u => speedTotalBytesPerSec(u),
|
||||
usage: u => Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0),
|
||||
expires: u => u.expires_at ? new Date(u.expires_at).getTime() : Infinity,
|
||||
owner: u => String(u.owner_username || "").toLowerCase(),
|
||||
};
|
||||
// Columns that default to descending on first click (most/online first).
|
||||
const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "usage"]);
|
||||
const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "speed", "usage"]);
|
||||
const USER_SORT_OPTIONS = [
|
||||
["username", "User"], ["status", "Status"], ["auth", "Auth"], ["conn", "Connections"],
|
||||
["usage", "Usage"], ["expires", "Expiry"], ["max", "Max"], ["up", "Up"], ["down", "Dn"],
|
||||
["owner", "Owner"],
|
||||
["speed", "Speed"], ["usage", "Usage"], ["expires", "Expiry"], ["max", "Max"],
|
||||
["up", "Up"], ["down", "Dn"], ["owner", "Owner"],
|
||||
];
|
||||
const USER_FILTER_OPTIONS = [
|
||||
["all", "All"], ["online", "Online"], ["offline", "Offline"],
|
||||
@@ -227,25 +228,36 @@ function renderUsers(users) {
|
||||
users.forEach(u => {
|
||||
const on = (u.active_conns || 0) > 0;
|
||||
const tr = document.createElement("tr");
|
||||
// Every cell carries its column label so the table can collapse into
|
||||
// labelled cards on phones instead of scrolling sideways. "wide" cells span
|
||||
// the full card width there.
|
||||
const cells = [
|
||||
u.username,
|
||||
on ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("idle")}</span>`,
|
||||
u.use_pam ? "PAM" : (u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password"),
|
||||
u.active_conns ?? 0,
|
||||
u.max_connections || 0,
|
||||
u.limit_mbps_up || 0,
|
||||
u.limit_mbps_down || 0,
|
||||
sshTrafficHTML(u),
|
||||
u.expires_at ? fmtDate(u.expires_at) : "—",
|
||||
{ label:"User", text:u.username, cls:"cell-primary" },
|
||||
{ label:"Status", html: on ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("idle")}</span>` },
|
||||
{ label:"Auth", text: u.use_pam ? "PAM" : (u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password") },
|
||||
{ label:"Conn", text: String(u.active_conns ?? 0) },
|
||||
{ label:"Max", text: String(u.max_connections || 0) },
|
||||
// "Up"/"Dn" are speed limits, not current speed: spell that out on the
|
||||
// card layout where the label sits right next to the live speed.
|
||||
{ label:"Up", cardLabel:"Limit up", text: String(u.limit_mbps_up || 0) },
|
||||
{ label:"Dn", cardLabel:"Limit down", text: String(u.limit_mbps_down || 0) },
|
||||
{ label:"Speed", html: speedHTML(u.up_bytes_per_sec, u.down_bytes_per_sec), small:true, cls:"cell-wide" },
|
||||
{ label:"Traffic", html: sshTrafficHTML(u), small:true, cls:"cell-wide" },
|
||||
{ label:"Expires", text: u.expires_at ? fmtDate(u.expires_at) : "—" },
|
||||
];
|
||||
if (isSA) cells.push(u.owner_username || "—");
|
||||
cells.forEach((c, i) => {
|
||||
if (isSA) cells.push({ label:"Owner", text: u.owner_username || "—" });
|
||||
cells.forEach(cell => {
|
||||
const td = document.createElement("td");
|
||||
if (i === 1 || i === 7) td.innerHTML = c; else td.textContent = c;
|
||||
if (i === 7) td.style.fontSize = ".7rem";
|
||||
td.dataset.label = t(cell.cardLabel || cell.label);
|
||||
if (cell.cls) td.className = cell.cls;
|
||||
if (cell.html !== undefined) td.innerHTML = cell.html;
|
||||
else td.textContent = cell.text ?? "—";
|
||||
if (cell.small) td.style.fontSize = ".7rem";
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const tdA = document.createElement("td");
|
||||
tdA.dataset.label = t("Actions");
|
||||
tdA.className = "cell-actions";
|
||||
const editBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillUserForm(u),
|
||||
@@ -268,7 +280,7 @@ function renderUsers(users) {
|
||||
if (!users.length) {
|
||||
const row = document.createElement("tr");
|
||||
const cell = document.createElement("td");
|
||||
cell.colSpan = isSA ? 11 : 10;
|
||||
cell.colSpan = isSA ? 12 : 11;
|
||||
cell.className = "hint";
|
||||
cell.style.cssText = "padding:24px;text-align:center;";
|
||||
cell.textContent = t("No SSH users match this filter.");
|
||||
|
||||
+18
-12
@@ -26,14 +26,15 @@ const XRAY_CLIENT_SORT_EXTRACT = {
|
||||
status: c => c.expired ? 0 : 1,
|
||||
online: c => c.online ? 1 : 0,
|
||||
connections: c => Number(c.active_connections || 0),
|
||||
speed: c => speedTotalBytesPerSec(c),
|
||||
usage: c => Number(c.total_bytes || ((c.uplink_bytes || 0) + (c.downlink_bytes || 0)) || 0),
|
||||
expiry: c => c.expires_at ? new Date(c.expires_at).getTime() : Infinity,
|
||||
max: c => Number(c.max_conns || 0),
|
||||
};
|
||||
const XRAY_CLIENT_SORT_DEFAULT_DESC = new Set(["status", "online", "connections", "usage", "max"]);
|
||||
const XRAY_CLIENT_SORT_DEFAULT_DESC = new Set(["status", "online", "connections", "speed", "usage", "max"]);
|
||||
const XRAY_CLIENT_SORT_OPTIONS = [
|
||||
["name", "Name"], ["status", "Status"], ["online", "Online"],
|
||||
["connections", "Connections"], ["usage", "Usage"], ["expiry", "Expiry"], ["max", "Max"],
|
||||
["connections", "Connections"], ["speed", "Speed"], ["usage", "Usage"], ["expiry", "Expiry"], ["max", "Max"],
|
||||
];
|
||||
const XRAY_CLIENT_FILTER_OPTIONS = [
|
||||
["all", "All"], ["online", "Online"], ["offline", "Offline"],
|
||||
@@ -354,22 +355,27 @@ function renderInbounds(inbounds, options = {}) {
|
||||
tblWrap.innerHTML = `<div class="hint" style="padding:4px 0;">${t("No clients.")}</div>`;
|
||||
} else {
|
||||
const tbl = document.createElement("table");
|
||||
tbl.innerHTML = `<thead><tr><th data-sort-key="name">${t("Name")}</th><th>UUID</th><th>${t("Email")}</th><th data-sort-key="expiry">${t("Expiry")}</th><th data-sort-key="status">${t("Status")}</th><th data-sort-key="online">${t("Online")}</th><th data-sort-key="connections">${t("Conn")}</th><th data-sort-key="usage">${t("Traffic")}</th><th data-sort-key="max">${t("Max")}</th><th>${t("Actions")}</th></tr></thead>`;
|
||||
tbl.className = "table-cards";
|
||||
tbl.innerHTML = `<thead><tr><th data-sort-key="name">${t("Name")}</th><th>UUID</th><th>${t("Email")}</th><th data-sort-key="expiry">${t("Expiry")}</th><th data-sort-key="status">${t("Status")}</th><th data-sort-key="online">${t("Online")}</th><th data-sort-key="connections">${t("Conn")}</th><th data-sort-key="speed" title="${escapeHTML(t("Current up/down speed of the whole account, across all of its connections."))}">${t("Speed")}</th><th data-sort-key="usage">${t("Traffic")}</th><th data-sort-key="max">${t("Max")}</th><th>${t("Actions")}</th></tr></thead>`;
|
||||
const tbody = document.createElement("tbody");
|
||||
clients.forEach(c => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.clientId = String(c.id || "");
|
||||
// data-label drives the labelled card layout used on narrow screens.
|
||||
tr.innerHTML = `
|
||||
<td data-cell="name">${escapeHTML(c.name || "—")}</td>
|
||||
<td data-cell="uuid" style="font-family:monospace;font-size:.65rem;">${escapeHTML(c.id || "—")}</td>
|
||||
<td data-cell="email">${escapeHTML(c.email || "—")}</td>
|
||||
<td data-cell="expiry" style="font-size:.7rem;">${escapeHTML(clientExpiryLabel(c))}</td>
|
||||
<td data-cell="status">${clientStatusHTML(c)}</td>
|
||||
<td data-cell="online">${clientOnlineHTML(c)}</td>
|
||||
<td data-cell="connections" style="font-size:.7rem;">${escapeHTML(c.active_connections || 0)}</td>
|
||||
<td data-cell="traffic" style="font-size:.7rem;">${clientTrafficHTML(c)}</td>
|
||||
<td data-cell="max" style="font-size:.7rem;">${escapeHTML(c.max_conns || "∞")}</td>`;
|
||||
<td data-cell="name" data-label="${escapeHTML(t("Name"))}" class="cell-primary">${escapeHTML(c.name || "—")}</td>
|
||||
<td data-cell="uuid" data-label="UUID" class="cell-wide" style="font-family:monospace;font-size:.65rem;word-break:break-all;">${escapeHTML(c.id || "—")}</td>
|
||||
<td data-cell="email" data-label="${escapeHTML(t("Email"))}" class="cell-wide">${escapeHTML(c.email || "—")}</td>
|
||||
<td data-cell="expiry" data-label="${escapeHTML(t("Expiry"))}" style="font-size:.7rem;">${escapeHTML(clientExpiryLabel(c))}</td>
|
||||
<td data-cell="status" data-label="${escapeHTML(t("Status"))}">${clientStatusHTML(c)}</td>
|
||||
<td data-cell="online" data-label="${escapeHTML(t("Online"))}">${clientOnlineHTML(c)}</td>
|
||||
<td data-cell="connections" data-label="${escapeHTML(t("Conn"))}" style="font-size:.7rem;">${escapeHTML(c.active_connections || 0)}</td>
|
||||
<td data-cell="speed" data-label="${escapeHTML(t("Speed"))}" class="cell-wide" style="font-size:.7rem;">${speedHTML(c.up_bytes_per_sec, c.down_bytes_per_sec)}</td>
|
||||
<td data-cell="traffic" data-label="${escapeHTML(t("Traffic"))}" class="cell-wide" style="font-size:.7rem;">${clientTrafficHTML(c)}</td>
|
||||
<td data-cell="max" data-label="${escapeHTML(t("Max"))}" style="font-size:.7rem;">${escapeHTML(c.max_conns || "∞")}</td>`;
|
||||
const actTd = document.createElement("td");
|
||||
actTd.dataset.label = t("Actions");
|
||||
actTd.className = "cell-actions";
|
||||
actTd.style.whiteSpace = "nowrap";
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.className = "btn btn-ghost btn-sm";
|
||||
|
||||
+2
-2
@@ -281,10 +281,10 @@
|
||||
<span class="chip user-list-count" id="sshListCount"></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<table class="table-cards">
|
||||
<thead><tr>
|
||||
<th data-sort-key="username">User</th><th data-sort-key="status">Status</th><th data-sort-key="auth">Auth</th>
|
||||
<th data-sort-key="conn">Conn</th><th data-sort-key="max">Max</th><th data-sort-key="up">Up</th><th data-sort-key="down">Dn</th><th data-sort-key="usage">Traffic</th><th data-sort-key="expires">Expires</th>
|
||||
<th data-sort-key="conn">Conn</th><th data-sort-key="max">Max</th><th data-sort-key="up">Up</th><th data-sort-key="down">Dn</th><th data-sort-key="speed" title="Current up/down speed of the whole account, across all of its connections.">Speed</th><th data-sort-key="usage">Traffic</th><th data-sort-key="expires">Expires</th>
|
||||
<th id="ownerColHead" data-sort-key="owner" class="superadmin-only hidden">Owner</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
// Live per-account bandwidth. The panel already keeps cumulative uploaded and
|
||||
// downloaded byte counters for every SSH user and Xray client; this file turns
|
||||
// those counters into a current speed so the UI can show "↑ 12 Mbps ↓ 40 Mbps"
|
||||
// for the whole account instead of only lifetime totals. Speeds are always the
|
||||
// sum of every connection the account has open, because the counters they are
|
||||
// derived from are per account, not per connection.
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bandwidthRate is a smoothed instantaneous speed in bytes per second.
|
||||
type bandwidthRate struct {
|
||||
UpBytesPerSec float64
|
||||
DownBytesPerSec float64
|
||||
}
|
||||
|
||||
func (r bandwidthRate) isZero() bool {
|
||||
return r.UpBytesPerSec == 0 && r.DownBytesPerSec == 0
|
||||
}
|
||||
|
||||
type bandwidthSample struct {
|
||||
up int64
|
||||
down int64
|
||||
at time.Time
|
||||
rate bandwidthRate
|
||||
}
|
||||
|
||||
// bandwidthSampler converts monotonically increasing byte counters into a
|
||||
// speed. Deltas smaller than minSampleInterval are ignored so a double sample
|
||||
// cannot divide by an almost-zero interval, and a counter that moves backwards
|
||||
// (traffic reset, account recreated) re-baselines instead of reporting a
|
||||
// nonsensical negative or huge rate.
|
||||
type bandwidthSampler struct {
|
||||
mu sync.Mutex
|
||||
samples map[string]bandwidthSample
|
||||
|
||||
// tau is the exponential smoothing time constant. Larger values give a
|
||||
// calmer number; zero disables smoothing.
|
||||
tau time.Duration
|
||||
// staleAfter makes Rate report zero for accounts that stopped being
|
||||
// sampled (idle Xray clients dropped by the stats poller, for example),
|
||||
// instead of freezing the last speed on screen forever.
|
||||
staleAfter time.Duration
|
||||
}
|
||||
|
||||
const minBandwidthSampleInterval = 250 * time.Millisecond
|
||||
|
||||
func newBandwidthSampler(tau, staleAfter time.Duration) *bandwidthSampler {
|
||||
return &bandwidthSampler{
|
||||
samples: make(map[string]bandwidthSample),
|
||||
tau: tau,
|
||||
staleAfter: staleAfter,
|
||||
}
|
||||
}
|
||||
|
||||
// Observe records the current cumulative counters for key. The first
|
||||
// observation only establishes a baseline; the rate stays zero until a second
|
||||
// one arrives.
|
||||
func (s *bandwidthSampler) Observe(key string, up, down int64, now time.Time) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if up < 0 {
|
||||
up = 0
|
||||
}
|
||||
if down < 0 {
|
||||
down = 0
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
prev, ok := s.samples[key]
|
||||
if !ok {
|
||||
s.samples[key] = bandwidthSample{up: up, down: down, at: now}
|
||||
return
|
||||
}
|
||||
// Counters went backwards: the account's traffic was reset or the entry was
|
||||
// recycled. Start over from this value.
|
||||
if up < prev.up || down < prev.down {
|
||||
s.samples[key] = bandwidthSample{up: up, down: down, at: now}
|
||||
return
|
||||
}
|
||||
dt := now.Sub(prev.at)
|
||||
if dt < minBandwidthSampleInterval {
|
||||
return
|
||||
}
|
||||
seconds := dt.Seconds()
|
||||
instant := bandwidthRate{
|
||||
UpBytesPerSec: float64(up-prev.up) / seconds,
|
||||
DownBytesPerSec: float64(down-prev.down) / seconds,
|
||||
}
|
||||
next := instant
|
||||
if s.tau > 0 && !prev.rate.isZero() {
|
||||
// alpha derived from the real interval so an irregular sampling
|
||||
// cadence still converges on the true average.
|
||||
alpha := 1 - math.Exp(-seconds/s.tau.Seconds())
|
||||
if alpha > 1 {
|
||||
alpha = 1
|
||||
}
|
||||
next = bandwidthRate{
|
||||
UpBytesPerSec: prev.rate.UpBytesPerSec + alpha*(instant.UpBytesPerSec-prev.rate.UpBytesPerSec),
|
||||
DownBytesPerSec: prev.rate.DownBytesPerSec + alpha*(instant.DownBytesPerSec-prev.rate.DownBytesPerSec),
|
||||
}
|
||||
}
|
||||
if next.UpBytesPerSec < 0 {
|
||||
next.UpBytesPerSec = 0
|
||||
}
|
||||
if next.DownBytesPerSec < 0 {
|
||||
next.DownBytesPerSec = 0
|
||||
}
|
||||
s.samples[key] = bandwidthSample{up: up, down: down, at: now, rate: next}
|
||||
}
|
||||
|
||||
// Rate returns the last known speed for key. Stale entries report zero.
|
||||
func (s *bandwidthSampler) Rate(key string) (bandwidthRate, bool) {
|
||||
if s == nil {
|
||||
return bandwidthRate{}, false
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return bandwidthRate{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.rateLocked(key)
|
||||
}
|
||||
|
||||
// RateForKeys returns the first known speed among keys. Xray clients are
|
||||
// tracked under their UUID in native mode and under their email in external
|
||||
// mode, so callers pass every identifier the client may be stored under.
|
||||
func (s *bandwidthSampler) RateForKeys(keys ...string) (bandwidthRate, bool) {
|
||||
if s == nil {
|
||||
return bandwidthRate{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if rate, ok := s.rateLocked(key); ok {
|
||||
return rate, true
|
||||
}
|
||||
}
|
||||
return bandwidthRate{}, false
|
||||
}
|
||||
|
||||
func (s *bandwidthSampler) rateLocked(key string) (bandwidthRate, bool) {
|
||||
sample, ok := s.samples[key]
|
||||
if !ok {
|
||||
return bandwidthRate{}, false
|
||||
}
|
||||
if s.staleAfter > 0 && !sample.at.IsZero() && time.Since(sample.at) > s.staleAfter {
|
||||
return bandwidthRate{}, true
|
||||
}
|
||||
return sample.rate, true
|
||||
}
|
||||
|
||||
// Retain drops every tracked key that is not in keep, so the map cannot grow
|
||||
// forever as accounts are deleted or recreated.
|
||||
func (s *bandwidthSampler) Retain(keep map[string]struct{}) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for key := range s.samples {
|
||||
if _, ok := keep[key]; !ok {
|
||||
delete(s.samples, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SSH accounts ----
|
||||
|
||||
const sshBandwidthSampleInterval = 2 * time.Second
|
||||
|
||||
var sshBandwidth = newBandwidthSampler(5*time.Second, 20*time.Second)
|
||||
|
||||
func startSSHUserRateSampler() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(sshBandwidthSampleInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
sampleSSHUserRates(time.Now())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func sampleSSHUserRates(now time.Time) {
|
||||
if userMgr == nil {
|
||||
return
|
||||
}
|
||||
states := userMgr.List()
|
||||
active := make(map[string]struct{}, len(states))
|
||||
for _, u := range states {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
u.mu.Lock()
|
||||
username := strings.TrimSpace(u.Cfg.Username)
|
||||
u.mu.Unlock()
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
sshBandwidth.Observe(
|
||||
username,
|
||||
atomic.LoadInt64(&u.TotalUplinkBytes),
|
||||
atomic.LoadInt64(&u.TotalDownlinkBytes),
|
||||
now,
|
||||
)
|
||||
active[username] = struct{}{}
|
||||
}
|
||||
sshBandwidth.Retain(active)
|
||||
}
|
||||
|
||||
func sshUserRate(username string) bandwidthRate {
|
||||
rate, _ := sshBandwidth.Rate(username)
|
||||
return rate
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBandwidthSamplerFirstObservationIsBaselineOnly(t *testing.T) {
|
||||
s := newBandwidthSampler(0, time.Minute)
|
||||
now := time.Now()
|
||||
s.Observe("bob", 10_000, 20_000, now)
|
||||
rate, ok := s.Rate("bob")
|
||||
if !ok {
|
||||
t.Fatal("expected the account to be tracked after the first observation")
|
||||
}
|
||||
if !rate.isZero() {
|
||||
t.Fatalf("first observation must not report a speed, got %+v", rate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandwidthSamplerComputesBytesPerSecond(t *testing.T) {
|
||||
s := newBandwidthSampler(0, time.Minute) // no smoothing: exact delta/dt
|
||||
now := time.Now()
|
||||
s.Observe("bob", 0, 0, now)
|
||||
// 2 MB up and 10 MB down over 2 seconds.
|
||||
s.Observe("bob", 2<<20, 10<<20, now.Add(2*time.Second))
|
||||
rate, _ := s.Rate("bob")
|
||||
if wantUp := float64(1 << 20); rate.UpBytesPerSec != wantUp {
|
||||
t.Fatalf("up = %v, want %v", rate.UpBytesPerSec, wantUp)
|
||||
}
|
||||
if wantDown := float64(5 << 20); rate.DownBytesPerSec != wantDown {
|
||||
t.Fatalf("down = %v, want %v", rate.DownBytesPerSec, wantDown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandwidthSamplerIgnoresSamplesTakenTooCloseTogether(t *testing.T) {
|
||||
s := newBandwidthSampler(0, time.Minute)
|
||||
now := time.Now()
|
||||
s.Observe("bob", 0, 0, now)
|
||||
s.Observe("bob", 5<<20, 5<<20, now.Add(10*time.Millisecond))
|
||||
rate, _ := s.Rate("bob")
|
||||
if !rate.isZero() {
|
||||
t.Fatalf("a 10ms interval must not produce a speed, got %+v", rate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandwidthSamplerRebaselinesAfterTrafficReset(t *testing.T) {
|
||||
s := newBandwidthSampler(0, time.Minute)
|
||||
now := time.Now()
|
||||
s.Observe("bob", 0, 0, now)
|
||||
s.Observe("bob", 4<<20, 4<<20, now.Add(2*time.Second))
|
||||
// Panel reset the account's traffic: counters go back to zero.
|
||||
s.Observe("bob", 0, 0, now.Add(4*time.Second))
|
||||
rate, _ := s.Rate("bob")
|
||||
if !rate.isZero() {
|
||||
t.Fatalf("counters moving backwards must reset the speed, got %+v", rate)
|
||||
}
|
||||
s.Observe("bob", 2<<20, 0, now.Add(6*time.Second))
|
||||
rate, _ = s.Rate("bob")
|
||||
if wantUp := float64(1 << 20); rate.UpBytesPerSec != wantUp {
|
||||
t.Fatalf("up after reset = %v, want %v", rate.UpBytesPerSec, wantUp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandwidthSamplerReportsZeroWhenIdle(t *testing.T) {
|
||||
s := newBandwidthSampler(0, time.Minute)
|
||||
now := time.Now()
|
||||
s.Observe("bob", 0, 0, now)
|
||||
s.Observe("bob", 4<<20, 4<<20, now.Add(2*time.Second))
|
||||
s.Observe("bob", 4<<20, 4<<20, now.Add(4*time.Second))
|
||||
rate, _ := s.Rate("bob")
|
||||
if !rate.isZero() {
|
||||
t.Fatalf("unchanged counters must report an idle account, got %+v", rate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandwidthSamplerDropsStaleSpeeds(t *testing.T) {
|
||||
s := newBandwidthSampler(0, time.Second)
|
||||
now := time.Now().Add(-time.Hour)
|
||||
s.Observe("bob", 0, 0, now)
|
||||
s.Observe("bob", 4<<20, 4<<20, now.Add(2*time.Second))
|
||||
rate, ok := s.Rate("bob")
|
||||
if !ok {
|
||||
t.Fatal("expected the account to still be tracked")
|
||||
}
|
||||
if !rate.isZero() {
|
||||
t.Fatalf("an hour-old sample must not still report a speed, got %+v", rate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandwidthSamplerSmoothsWithTimeConstant(t *testing.T) {
|
||||
s := newBandwidthSampler(5*time.Second, time.Minute)
|
||||
now := time.Now()
|
||||
s.Observe("bob", 0, 0, now)
|
||||
// First real sample has no previous rate to blend with, so it lands exactly.
|
||||
s.Observe("bob", 2<<20, 0, now.Add(2*time.Second))
|
||||
first, _ := s.Rate("bob")
|
||||
if first.UpBytesPerSec != float64(1<<20) {
|
||||
t.Fatalf("first speed = %v, want %v", first.UpBytesPerSec, float64(1<<20))
|
||||
}
|
||||
// Traffic stops: the smoothed value has to fall without jumping to zero.
|
||||
s.Observe("bob", 2<<20, 0, now.Add(4*time.Second))
|
||||
second, _ := s.Rate("bob")
|
||||
if second.UpBytesPerSec <= 0 || second.UpBytesPerSec >= first.UpBytesPerSec {
|
||||
t.Fatalf("smoothed speed = %v, want a value between 0 and %v", second.UpBytesPerSec, first.UpBytesPerSec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandwidthSamplerRateForKeysAndRetain(t *testing.T) {
|
||||
s := newBandwidthSampler(0, time.Minute)
|
||||
now := time.Now()
|
||||
s.Observe("uuid-1", 0, 0, now)
|
||||
s.Observe("uuid-1", 1<<20, 0, now.Add(1*time.Second))
|
||||
if _, ok := s.RateForKeys("", "unknown@example", "uuid-1"); !ok {
|
||||
t.Fatal("RateForKeys must find the client under any of its identifiers")
|
||||
}
|
||||
s.Retain(map[string]struct{}{"uuid-2": {}})
|
||||
if _, ok := s.Rate("uuid-1"); ok {
|
||||
t.Fatal("Retain must drop accounts that no longer exist")
|
||||
}
|
||||
}
|
||||
@@ -1796,6 +1796,10 @@ type UserDTO struct {
|
||||
TotalUplinkBytes int64 `json:"total_uplink_bytes"`
|
||||
TotalDownlinkBytes int64 `json:"total_downlink_bytes"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
// Live account-wide speed in bytes per second, summed across every
|
||||
// connection the user has open.
|
||||
UpBytesPerSec float64 `json:"up_bytes_per_sec"`
|
||||
DownBytesPerSec float64 `json:"down_bytes_per_sec"`
|
||||
QuotaExceeded bool `json:"quota_exceeded"`
|
||||
TOTPSecret string `json:"totp_secret,omitempty"`
|
||||
TOTPPeriod int `json:"totp_period"`
|
||||
@@ -1840,6 +1844,8 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
rate := sshUserRate(cfg.Username)
|
||||
|
||||
out = append(out, UserDTO{
|
||||
Username: cfg.Username,
|
||||
ActiveConns: c,
|
||||
@@ -1853,6 +1859,8 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
TotalUplinkBytes: totalUp,
|
||||
TotalDownlinkBytes: totalDown,
|
||||
TotalBytes: totalBytes,
|
||||
UpBytesPerSec: rate.UpBytesPerSec,
|
||||
DownBytesPerSec: rate.DownBytesPerSec,
|
||||
QuotaExceeded: cfg.DataQuotaBytes > 0 && totalBytes >= cfg.DataQuotaBytes,
|
||||
TOTPSecret: cfg.TOTPSecret,
|
||||
TOTPPeriod: cfg.TOTPPeriod,
|
||||
@@ -3298,6 +3306,9 @@ func main() {
|
||||
primeCurrentStats()
|
||||
startStatsCollector()
|
||||
|
||||
// Turn the per-account byte counters into live up/down speeds for the panel.
|
||||
startSSHUserRateSampler()
|
||||
|
||||
adminAddr := os.Getenv("ADMIN_HTTP_ADDR")
|
||||
if adminAddr == "" {
|
||||
adminAddr = "0.0.0.0:9090"
|
||||
|
||||
+86
-5
@@ -257,6 +257,7 @@ type XrayManager struct {
|
||||
lastStatsErr string
|
||||
lastStatsPoll time.Time
|
||||
pollStarted bool
|
||||
rateSamplerStarted bool
|
||||
|
||||
nativeDBMu sync.Mutex
|
||||
nativeTrafficPersistMu sync.Mutex
|
||||
@@ -319,6 +320,7 @@ func initXrayManager(cfg *XrayConfig) {
|
||||
// external `xray api statsquery` poller is not started (it would overwrite
|
||||
// the native counters with errors from a non-existent CLI endpoint).
|
||||
xrayMgr.startNativeStatsFlusher()
|
||||
xrayMgr.startRateSampler()
|
||||
if !cfg.UseNative() {
|
||||
xrayMgr.startStatsPoller()
|
||||
}
|
||||
@@ -886,6 +888,73 @@ func (m *XrayManager) startStatsPoller() {
|
||||
}()
|
||||
}
|
||||
|
||||
// Live per-client speed, derived from the same cumulative counters the panel
|
||||
// already reports as lifetime traffic.
|
||||
var xrayBandwidth = newBandwidthSampler(6*time.Second, 45*time.Second)
|
||||
|
||||
const xrayNativeRateSampleInterval = 2 * time.Second
|
||||
|
||||
// startRateSampler keeps xrayBandwidth fresh in native mode, where the
|
||||
// in-process runtime updates the counters continuously. In external mode the
|
||||
// counters only move once per stats poll (15s by default), so refreshRuntimeStats
|
||||
// feeds the sampler at its own cadence instead — sampling faster than the source
|
||||
// updates would show alternating spikes and zeros. The mode is re-checked on
|
||||
// every tick because a hot reload can switch it while running.
|
||||
func (m *XrayManager) startRateSampler() {
|
||||
m.mu.Lock()
|
||||
if m.rateSamplerStarted {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.rateSamplerStarted = true
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(xrayNativeRateSampleInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if !m.usesNativeSnapshot() {
|
||||
continue
|
||||
}
|
||||
m.sampleRuntimeRates(time.Now())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *XrayManager) usesNativeSnapshot() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg != nil && m.cfg.UseNative()
|
||||
}
|
||||
|
||||
func (m *XrayManager) sampleRuntimeRates(now time.Time) {
|
||||
type counterSnapshot struct {
|
||||
key string
|
||||
uplink int64
|
||||
downlink int64
|
||||
}
|
||||
m.statsMu.RLock()
|
||||
snapshots := make([]counterSnapshot, 0, len(m.statsByEmail))
|
||||
for key, st := range m.statsByEmail {
|
||||
snapshots = append(snapshots, counterSnapshot{key: key, uplink: st.Uplink, downlink: st.Downlink})
|
||||
}
|
||||
m.statsMu.RUnlock()
|
||||
|
||||
active := make(map[string]struct{}, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
xrayBandwidth.Observe(snapshot.key, snapshot.uplink, snapshot.downlink, now)
|
||||
active[snapshot.key] = struct{}{}
|
||||
}
|
||||
xrayBandwidth.Retain(active)
|
||||
}
|
||||
|
||||
// RuntimeRateForKeys resolves a client's live speed. Clients are tracked under
|
||||
// their UUID in native mode and under their stats-API email in external mode,
|
||||
// so callers pass every identifier the client may be stored under.
|
||||
func (m *XrayManager) RuntimeRateForKeys(keys ...string) (bandwidthRate, bool) {
|
||||
return xrayBandwidth.RateForKeys(keys...)
|
||||
}
|
||||
|
||||
func (m *XrayManager) isRunningSnapshot() bool {
|
||||
m.mu.Lock()
|
||||
native := m.cfg != nil && m.cfg.UseNative()
|
||||
@@ -965,9 +1034,12 @@ func (m *XrayManager) refreshRuntimeStats() {
|
||||
if m.statsByEmail == nil {
|
||||
m.statsByEmail = make(map[string]xrayRuntimeStat, len(traffic))
|
||||
}
|
||||
seen := make(map[string]bool, len(traffic))
|
||||
// External mode: the counters only move once per poll, so this is also the
|
||||
// natural cadence for the live speed sampler.
|
||||
active := make(map[string]struct{}, len(traffic))
|
||||
for email, counters := range traffic {
|
||||
seen[email] = true
|
||||
active[email] = struct{}{}
|
||||
xrayBandwidth.Observe(email, counters.Uplink, counters.Downlink, now)
|
||||
prev := m.statsByEmail[email]
|
||||
st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive, ActiveConnections: prev.ActiveConnections}
|
||||
changed := counters.Uplink != prev.Uplink || counters.Downlink != prev.Downlink
|
||||
@@ -979,9 +1051,10 @@ func (m *XrayManager) refreshRuntimeStats() {
|
||||
}
|
||||
m.statsByEmail[email] = st
|
||||
}
|
||||
// Keep old entries, but do not delete them immediately. Xray may omit zero
|
||||
// counters for users that have not moved traffic yet.
|
||||
_ = seen
|
||||
// Keep old stat entries, but do not delete them immediately: Xray may omit
|
||||
// zero counters for users that have not moved traffic yet. Speed samples are
|
||||
// dropped for absent users because a missing baseline only costs one poll.
|
||||
xrayBandwidth.Retain(active)
|
||||
}
|
||||
|
||||
func (m *XrayManager) refreshRuntimeStatsIfStale(maxAge time.Duration) {
|
||||
@@ -2161,6 +2234,10 @@ type XrayClientInfo struct {
|
||||
DownlinkBytes int64 `json:"downlink_bytes,omitempty"`
|
||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||
ActiveConnections int `json:"active_connections,omitempty"`
|
||||
// Live speed in bytes per second for the whole client, summed across every
|
||||
// connection it has open.
|
||||
UpBytesPerSec float64 `json:"up_bytes_per_sec"`
|
||||
DownBytesPerSec float64 `json:"down_bytes_per_sec"`
|
||||
// Metadata from PostgreSQL (enriched by handleXrayInbounds)
|
||||
Name string `json:"name,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
@@ -2565,6 +2642,10 @@ func applyXrayRuntimeStats(c *XrayClientInfo) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if rate, ok := xrayMgr.RuntimeRateForKeys(c.Email, c.UUID, c.Name); ok {
|
||||
c.UpBytesPerSec = rate.UpBytesPerSec
|
||||
c.DownBytesPerSec = rate.DownBytesPerSec
|
||||
}
|
||||
st, ok := xrayMgr.RuntimeStatsForKeys(c.Email, c.UUID, c.Name)
|
||||
if !ok {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user