quota reset button

This commit is contained in:
2026-07-14 22:42:49 -03:00
parent ed0e240241
commit ff175174e4
7 changed files with 264 additions and 9 deletions
+18
View File
@@ -172,6 +172,24 @@ Object.assign(I18N_TEXT["pt-BR"], {
"Reseller areas":"Áreas de revendedores","Reseller area":"Área de revendedores","Create reseller":"Criar revendedor","Edit reseller":"Editar revendedor","Registered resellers":"Revendedores cadastrados","Reseller list section copy":"Consulte cotas, consumo compartilhado, validade e situação de cada parceiro.","Reseller create section copy":"Defina login, limite compartilhado, validade e acesso em uma tela dedicada.","Reseller saved successfully.":"Revendedor salvo com sucesso.", "Reseller areas":"Áreas de revendedores","Reseller area":"Área de revendedores","Create reseller":"Criar revendedor","Edit reseller":"Editar revendedor","Registered resellers":"Revendedores cadastrados","Reseller list section copy":"Consulte cotas, consumo compartilhado, validade e situação de cada parceiro.","Reseller create section copy":"Defina login, limite compartilhado, validade e acesso em uma tela dedicada.","Reseller saved successfully.":"Revendedor salvo com sucesso.",
"Configuration areas":"Áreas de configuração","Configuration area":"Área de configuração","Network and SSH":"Rede e SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"Encaminhadores TLS","01 · Base":"01 · Base","02 · DNS tunnel":"02 · Túnel DNS","03 · UDP":"03 · UDP","04 · Security":"04 · Segurança","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, limites padrão, tempo ocioso e o banner de conexão.","SlowDNS section copy":"Gerencie domínios, DNS local, capacidade, filas e reinício controlado.","UDP section copy":"Defina listener, capacidade, expiração de mapa e reinício do serviço.","TLS section copy":"Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.","Xray core section copy":"Ative o core, escolha o runtime e aplique os ajustes nativos seguros." "Configuration areas":"Áreas de configuração","Configuration area":"Área de configuração","Network and SSH":"Rede e SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"Encaminhadores TLS","01 · Base":"01 · Base","02 · DNS tunnel":"02 · Túnel DNS","03 · UDP":"03 · UDP","04 · Security":"04 · Segurança","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, limites padrão, tempo ocioso e o banner de conexão.","SlowDNS section copy":"Gerencie domínios, DNS local, capacidade, filas e reinício controlado.","UDP section copy":"Defina listener, capacidade, expiração de mapa e reinício do serviço.","TLS section copy":"Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.","Xray core section copy":"Ative o core, escolha o runtime e aplique os ajustes nativos seguros."
}); });
Object.assign(I18N_TEXT["en-US"], {
"Reset":"Reset","Reset traffic":"Reset traffic","Reset SSH traffic":"Reset SSH traffic","Reset Xray traffic":"Reset Xray traffic",
"Reset traffic for user \"{name}\"?":"Reset traffic for user \"{name}\"?","Reset traffic for client {id}…?":"Reset traffic for client {id}…?",
"Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.":"Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.",
"Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.":"Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.",
"Resetting traffic for {name}…":"Resetting traffic for {name}…","Resetting traffic for client {id}…":"Resetting traffic for client {id}…",
"Traffic reset successfully.":"Traffic reset successfully.","Xray traffic reset successfully.":"Xray traffic reset successfully.",
"Could not reset traffic: {error}":"Could not reset traffic: {error}","Reset traffic counter":"Reset traffic counter"
});
Object.assign(I18N_TEXT["pt-BR"], {
"Reset":"Zerar","Reset traffic":"Zerar tráfego","Reset SSH traffic":"Zerar tráfego SSH","Reset Xray traffic":"Zerar tráfego Xray",
"Reset traffic for user \"{name}\"?":"Zerar o tráfego do usuário \"{name}\"?","Reset traffic for client {id}…?":"Zerar o tráfego do cliente {id}…?",
"Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.":"O consumo de upload e download voltará para zero. A conta, senha, validade e cota não serão alteradas.",
"Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.":"O consumo de upload e download voltará para zero. A conta, validade e cota não serão alteradas.",
"Resetting traffic for {name}…":"Zerando o tráfego de {name}…","Resetting traffic for client {id}…":"Zerando o tráfego do cliente {id}…",
"Traffic reset successfully.":"Tráfego zerado com sucesso.","Xray traffic reset successfully.":"Tráfego Xray zerado com sucesso.",
"Could not reset traffic: {error}":"Não foi possível zerar o tráfego: {error}","Reset traffic counter":"Zerar contador de tráfego"
});
const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))])); const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))]));
let currentLang = detectInitialLanguage(); let currentLang = detectInitialLanguage();
let i18nTranslating = false; let i18nTranslating = false;
+38 -1
View File
@@ -173,12 +173,18 @@ function renderUsers(users) {
className:"btn btn-ghost btn-sm", textContent:t("Edit"), className:"btn btn-ghost btn-sm", textContent:t("Edit"),
onclick: () => fillUserForm(u), onclick: () => fillUserForm(u),
}); });
const resetBtn = Object.assign(document.createElement("button"), {
className:"btn btn-warn btn-sm", textContent:t("Reset"),
style: "margin-left:4px;",
title: t("Reset traffic"),
onclick: () => resetUserTraffic(u.username, resetBtn),
});
const delBtn = Object.assign(document.createElement("button"), { const delBtn = Object.assign(document.createElement("button"), {
className:"btn btn-danger btn-sm", textContent:t("Del"), className:"btn btn-danger btn-sm", textContent:t("Del"),
style: "margin-left:4px;", style: "margin-left:4px;",
onclick: () => deleteUser(u.username), onclick: () => deleteUser(u.username),
}); });
tdA.append(editBtn, delBtn); tdA.append(editBtn, resetBtn, delBtn);
tr.appendChild(tdA); tr.appendChild(tdA);
usersBody.appendChild(tr); usersBody.appendChild(tr);
}); });
@@ -260,6 +266,37 @@ userForm.addEventListener("submit", async e => {
} }
}); });
async function resetUserTraffic(username, button) {
const accepted = await panelConfirm({
tone:"warning", icon:"↺", title:t("Reset SSH traffic"),
message:t("Reset traffic for user \"{name}\"?", {name: username}),
detail:t("Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged."),
confirmLabel:t("Reset traffic"),
});
if (!accepted) return;
const previousDisabled = !!button?.disabled;
if (button) button.disabled = true;
userStatus.textContent = t("Resetting traffic for {name}…", {name: username});
try {
const res = await api("/api/users/reset-traffic", {
method:"POST",
body: JSON.stringify({ username, server_id:selectedSSHServer() }),
});
if (!res.ok) throw new Error((await res.text()) || "reset failed");
userStatus.textContent = t("Traffic reset successfully.");
showPanelToast(t("Traffic reset successfully."), "success", t("SSH / SlowDNS"));
await loadUsers();
} catch (e) {
if (e.message === "auth") doAuthError();
else {
userStatus.textContent = t("Could not reset traffic: {error}", {error:e.message});
showPanelToast(t("Could not reset traffic: {error}", {error:e.message}), "error", t("SSH / SlowDNS"));
}
} finally {
if (button) button.disabled = previousDisabled;
}
}
async function deleteUser(username) { async function deleteUser(username) {
const accepted = await panelConfirm({ const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Delete SSH account"), tone:"danger", icon:"×", title:t("Delete SSH account"),
+45 -1
View File
@@ -244,12 +244,18 @@ function renderInbounds(inbounds, options = {}) {
editBtn.style.marginLeft = "4px"; editBtn.style.marginLeft = "4px";
editBtn.textContent = t("Edit"); editBtn.textContent = t("Edit");
editBtn.onclick = () => openEditXrayClient(ib.tag, c); editBtn.onclick = () => openEditXrayClient(ib.tag, c);
const resetBtn = document.createElement("button");
resetBtn.className = "btn btn-warn btn-sm";
resetBtn.style.marginLeft = "4px";
resetBtn.textContent = t("Reset");
resetBtn.title = t("Reset traffic");
resetBtn.onclick = () => resetXrayClientTraffic(c.id, resetBtn);
const delBtn = document.createElement("button"); const delBtn = document.createElement("button");
delBtn.className = "btn btn-danger btn-sm"; delBtn.className = "btn btn-danger btn-sm";
delBtn.style.marginLeft = "4px"; delBtn.style.marginLeft = "4px";
delBtn.textContent = t("Del"); delBtn.textContent = t("Del");
delBtn.onclick = () => removeClient(ib.tag, c.id); delBtn.onclick = () => removeClient(ib.tag, c.id);
actTd.append(copyBtn, editBtn, delBtn); actTd.append(copyBtn, editBtn, resetBtn, delBtn);
tr.appendChild(actTd); tr.appendChild(actTd);
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
@@ -394,6 +400,44 @@ async function submitXrayClientCreator(event) {
} }
} }
async function resetXrayClientTraffic(uuid, button) {
const shortID = String(uuid || "").slice(0, 8);
const accepted = await panelConfirm({
tone:"warning", icon:"↺", title:t("Reset Xray traffic"),
message:t("Reset traffic for client {id}…?", {id:shortID}),
detail:t("Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged."),
confirmLabel:t("Reset traffic"),
});
if (!accepted) return;
const previousDisabled = !!button?.disabled;
if (button) button.disabled = true;
xStatus.textContent = t("Resetting traffic for client {id}…", {id:shortID});
try {
const res = await api("/api/xray/clients/reset-traffic", {
method:"POST",
body:JSON.stringify({ uuid, server_id:selectedXrayServer() }),
});
if (!res.ok) throw new Error((await res.text()) || "reset failed");
xStatus.textContent = t("Xray traffic reset successfully.");
showPanelToast(t("Xray traffic reset successfully."), "success", t("Xray user"));
if (editingXrayClientId === uuid) {
const usage = document.getElementById("editXrayUsage");
if (usage) usage.value = "0 B (↑ 0 B · ↓ 0 B)";
const resetUsage = document.getElementById("editXrayResetUsage");
if (resetUsage) resetUsage.checked = false;
}
await loadInbounds({ force:true });
} catch (e) {
if (e.message === "auth") doAuthError();
else {
xStatus.textContent = t("Could not reset traffic: {error}", {error:e.message});
showPanelToast(t("Could not reset traffic: {error}", {error:e.message}), "error", t("Xray user"));
}
} finally {
if (button) button.disabled = previousDisabled;
}
}
async function removeClient(tag, uuid) { async function removeClient(tag, uuid) {
const accepted = await panelConfirm({ const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Remove Xray client"), tone:"danger", icon:"×", title:t("Remove Xray client"),
+3 -3
View File
@@ -1555,10 +1555,10 @@
<!-- app.js was split into ordered modules for maintainability. They are plain <!-- app.js was split into ordered modules for maintainability. They are plain
classic scripts sharing one global scope; `defer` preserves execution order, classic scripts sharing one global scope; `defer` preserves execution order,
so behavior is identical to the old single file. Keep this load order. --> so behavior is identical to the old single file. Keep this load order. -->
<script defer src="assets/js/01-core.js?v=20260714quota1"></script> <script defer src="assets/js/01-core.js?v=20260715quotareset1"></script>
<script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script> <script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260714quota1"></script> <script defer src="assets/js/03-ssh-users.js?v=20260715quotareset1"></script>
<script defer src="assets/js/04-xray.js?v=20260714quota1"></script> <script defer src="assets/js/04-xray.js?v=20260715quotareset1"></script>
<script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script> <script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script>
<script defer src="assets/js/06-servers.js?v=20260714pamfix1"></script> <script defer src="assets/js/06-servers.js?v=20260714pamfix1"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260714pamfix1"></script> <script defer src="assets/js/07-stats-logs.js?v=20260714pamfix1"></script>
+72
View File
@@ -399,6 +399,7 @@ type UserState struct {
totalBytes int64 totalBytes int64
pendingUplinkBytes int64 pendingUplinkBytes int64
pendingDownlinkBytes int64 pendingDownlinkBytes int64
trafficMu sync.RWMutex
quotaLimiter *rate.Limiter quotaLimiter *rate.Limiter
quotaLimiterMbps int quotaLimiterMbps int
} }
@@ -1688,6 +1689,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
// SSH user management (session required; role-filtered inside handlers) // SSH user management (session required; role-filtered inside handlers)
mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers))) mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers)))
mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store)))) mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store))))
mux.Handle("/api/users/reset-traffic", sessionMiddleware(http.HandlerFunc(handleResetUserTraffic(store))))
mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store)))) mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store))))
// Server stats: visible to authenticated sessions; reset remains superadmin-only. // Server stats: visible to authenticated sessions; reset remains superadmin-only.
@@ -1724,6 +1726,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds))) mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd))) mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate))) mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
mux.Handle("/api/xray/clients/reset-traffic", sessionMiddleware(http.HandlerFunc(handleXrayClientResetTraffic)))
mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove))) mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
// Superadmin-only: TLS certificate generation // Superadmin-only: TLS certificate generation
@@ -2065,6 +2068,75 @@ func handleCreateUser(store *Store) http.HandlerFunc {
} }
} }
func handleResetUserTraffic(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if store == nil {
http.Error(w, "database not configured", http.StatusServiceUnavailable)
return
}
var req struct {
Username string `json:"username"`
ServerID string `json:"server_id,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" {
http.Error(w, "username required", http.StatusBadRequest)
return
}
ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, store, req.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else if remote {
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, req.Username, sess.Username) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
req.ServerID = ""
body, _ := json.Marshal(req)
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/reset-traffic", body, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return
}
writeProxyResponse(w, status, data, ct)
return
}
var owner string
if err := store.db.QueryRowContext(ctx, `SELECT owner_username FROM ssh_users WHERE username=$1`, req.Username).Scan(&owner); err != nil {
if err == sql.ErrNoRows {
http.Error(w, "user not found", http.StatusNotFound)
} else {
http.Error(w, "db error", http.StatusInternalServerError)
}
return
}
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && strings.TrimSpace(owner) != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := resetSSHUserTrafficAccounting(ctx, store, req.Username); err != nil {
log.Printf("failed to reset SSH traffic for %s: %v", req.Username, err)
http.Error(w, "could not reset traffic", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "username": req.Username})
}
}
func handleDeleteUser(store *Store) http.HandlerFunc { func handleDeleteUser(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete { if r.Method != http.MethodDelete {
+24 -4
View File
@@ -100,9 +100,8 @@ func initSSHRuntimeUsage(u *UserState, uplink, downlink int64) {
atomic.StoreInt64(&u.pendingDownlinkBytes, 0) atomic.StoreInt64(&u.pendingDownlinkBytes, 0)
} }
func resetSSHRuntimeUsage(username string) { func resetSSHRuntimeUsageLocked(u *UserState) {
u, ok := userMgr.Get(username) if u == nil {
if !ok || u == nil {
return return
} }
initSSHRuntimeUsage(u, 0, 0) initSSHRuntimeUsage(u, 0, 0)
@@ -112,13 +111,30 @@ func resetSSHRuntimeUsage(username string) {
u.mu.Unlock() u.mu.Unlock()
} }
func resetSSHRuntimeUsage(username string) {
u, ok := userMgr.Get(username)
if !ok || u == nil {
return
}
u.trafficMu.Lock()
resetSSHRuntimeUsageLocked(u)
u.trafficMu.Unlock()
}
func resetSSHUserTrafficAccounting(ctx context.Context, store *Store, username string) error { func resetSSHUserTrafficAccounting(ctx context.Context, store *Store, username string) error {
u, _ := userMgr.Get(username)
if u != nil {
u.trafficMu.Lock()
defer u.trafficMu.Unlock()
}
sshTrafficPersistenceMu.Lock() sshTrafficPersistenceMu.Lock()
defer sshTrafficPersistenceMu.Unlock() defer sshTrafficPersistenceMu.Unlock()
if err := store.ResetSSHUserTraffic(ctx, username); err != nil { if err := store.ResetSSHUserTraffic(ctx, username); err != nil {
return err return err
} }
resetSSHRuntimeUsage(username) if u != nil {
resetSSHRuntimeUsageLocked(u)
}
return nil return nil
} }
@@ -221,6 +237,10 @@ type sshQuotaWriter struct {
} }
func (qw sshQuotaWriter) Write(p []byte) (int, error) { func (qw sshQuotaWriter) Write(p []byte) (int, error) {
if qw.user != nil {
qw.user.trafficMu.RLock()
defer qw.user.trafficMu.RUnlock()
}
allowed, quotaLimiter, stopAfter := reserveSSHUserBytes(qw.user, len(p)) allowed, quotaLimiter, stopAfter := reserveSSHUserBytes(qw.user, len(p))
if allowed <= 0 { if allowed <= 0 {
return 0, errDataQuotaExceeded return 0, errDataQuotaExceeded
+64
View File
@@ -2719,6 +2719,70 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
func handleXrayClientResetTraffic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct {
UUID string `json:"uuid"`
ServerID string `json:"server_id,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
req.UUID = strings.TrimSpace(req.UUID)
if req.UUID == "" {
http.Error(w, "uuid required", http.StatusBadRequest)
return
}
ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, statsStore, req.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else if remote {
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(ctx, ms, req.UUID, sess.Username) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
req.ServerID = ""
body, _ := json.Marshal(req)
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/xray/clients/reset-traffic", body, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return
}
writeProxyResponse(w, status, data, ct)
return
}
if statsStore == nil {
http.Error(w, "storage not available", http.StatusInternalServerError)
return
}
existing, err := statsStore.GetXrayClientMeta(ctx, req.UUID)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "client not found", http.StatusNotFound)
} else {
http.Error(w, "database error", http.StatusInternalServerError)
}
return
}
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := xrayMgr.resetNativeTrafficAccounting(ctx, statsStore, req.UUID, existing.Email); err != nil {
http.Error(w, "usage reset failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "uuid": req.UUID})
}
func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) { func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete { if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)