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
+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>