quota reset button
This commit is contained in:
@@ -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.",
|
||||
"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]))]));
|
||||
let currentLang = detectInitialLanguage();
|
||||
let i18nTranslating = false;
|
||||
|
||||
@@ -173,12 +173,18 @@ function renderUsers(users) {
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
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"), {
|
||||
className:"btn btn-danger btn-sm", textContent:t("Del"),
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteUser(u.username),
|
||||
});
|
||||
tdA.append(editBtn, delBtn);
|
||||
tdA.append(editBtn, resetBtn, delBtn);
|
||||
tr.appendChild(tdA);
|
||||
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) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Delete SSH account"),
|
||||
|
||||
@@ -244,12 +244,18 @@ function renderInbounds(inbounds, options = {}) {
|
||||
editBtn.style.marginLeft = "4px";
|
||||
editBtn.textContent = t("Edit");
|
||||
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");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.style.marginLeft = "4px";
|
||||
delBtn.textContent = t("Del");
|
||||
delBtn.onclick = () => removeClient(ib.tag, c.id);
|
||||
actTd.append(copyBtn, editBtn, delBtn);
|
||||
actTd.append(copyBtn, editBtn, resetBtn, delBtn);
|
||||
tr.appendChild(actTd);
|
||||
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) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Remove Xray client"),
|
||||
|
||||
+3
-3
@@ -1555,10 +1555,10 @@
|
||||
<!-- app.js was split into ordered modules for maintainability. They are plain
|
||||
classic scripts sharing one global scope; `defer` preserves execution 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/03-ssh-users.js?v=20260714quota1"></script>
|
||||
<script defer src="assets/js/04-xray.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=20260715quotareset1"></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/07-stats-logs.js?v=20260714pamfix1"></script>
|
||||
|
||||
@@ -399,6 +399,7 @@ type UserState struct {
|
||||
totalBytes int64
|
||||
pendingUplinkBytes int64
|
||||
pendingDownlinkBytes int64
|
||||
trafficMu sync.RWMutex
|
||||
quotaLimiter *rate.Limiter
|
||||
quotaLimiterMbps int
|
||||
}
|
||||
@@ -1688,6 +1689,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
// SSH user management (session required; role-filtered inside handlers)
|
||||
mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers)))
|
||||
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))))
|
||||
|
||||
// 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/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
|
||||
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)))
|
||||
|
||||
// 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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
|
||||
@@ -100,9 +100,8 @@ func initSSHRuntimeUsage(u *UserState, uplink, downlink int64) {
|
||||
atomic.StoreInt64(&u.pendingDownlinkBytes, 0)
|
||||
}
|
||||
|
||||
func resetSSHRuntimeUsage(username string) {
|
||||
u, ok := userMgr.Get(username)
|
||||
if !ok || u == nil {
|
||||
func resetSSHRuntimeUsageLocked(u *UserState) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
initSSHRuntimeUsage(u, 0, 0)
|
||||
@@ -112,13 +111,30 @@ func resetSSHRuntimeUsage(username string) {
|
||||
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 {
|
||||
u, _ := userMgr.Get(username)
|
||||
if u != nil {
|
||||
u.trafficMu.Lock()
|
||||
defer u.trafficMu.Unlock()
|
||||
}
|
||||
sshTrafficPersistenceMu.Lock()
|
||||
defer sshTrafficPersistenceMu.Unlock()
|
||||
if err := store.ResetSSHUserTraffic(ctx, username); err != nil {
|
||||
return err
|
||||
}
|
||||
resetSSHRuntimeUsage(username)
|
||||
if u != nil {
|
||||
resetSSHRuntimeUsageLocked(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -221,6 +237,10 @@ type sshQuotaWriter struct {
|
||||
}
|
||||
|
||||
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))
|
||||
if allowed <= 0 {
|
||||
return 0, errDataQuotaExceeded
|
||||
|
||||
@@ -2719,6 +2719,70 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
if r.Method != http.MethodDelete {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
|
||||
Reference in New Issue
Block a user