Speed meter

This commit is contained in:
2026-08-04 18:08:44 -03:00
parent 8df19f01e0
commit 54981f7348
10 changed files with 611 additions and 65 deletions
+6 -2
View File
@@ -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` (010000), `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `owner_username` (string, optional — superadmin only), `server_id` (string, optional).
+41
View File
@@ -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;}
}
+39
View File
@@ -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.",
});
+29 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+231
View File
@@ -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
}
+121
View File
@@ -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")
}
}
+11
View File
@@ -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
View File
@@ -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