Fix admin panel

This commit is contained in:
2026-07-13 02:00:27 -03:00
parent 047e4be207
commit 64b1fc5cb3
12 changed files with 535 additions and 146 deletions
+127
View File
@@ -133,6 +133,28 @@ Object.assign(I18N_TEXT["pt-BR"], {
"Configured inbounds":"Inbounds configurados","Visual inbound help":"Edite qualquer cartão visualmente ou use JSON para campos avançados.","New inbound":"Novo inbound","Visual editor":"Editor visual","Add inbound":"Adicionar inbound","Save changes":"Salvar alterações","Duplicate":"Duplicar","Remove":"Remover","Save config and restart":"Salvar configuração e reiniciar",
"Old XHTTP configuration?":"Configuração XHTTP antiga?","Legacy XHTTP migration copy":"Use “Ativar SSH /ssh” no cartão do inbound existente. O painel mantém o inbound, os clientes e todas as opções atuais; adiciona somente a rota SSH e reinicia o Xray.","Enable SSH /ssh":"Ativar SSH /ssh","SSH /ssh enabled":"SSH /ssh ativado","Legacy XHTTP migration":"Migração de configuração XHTTP antiga"
});
Object.assign(I18N_TEXT["en-US"], {
"Infrastructure":"Infrastructure","Infrastructure areas":"Infrastructure areas","Infrastructure area":"Infrastructure area","Waiting for data":"Waiting for data","Operation":"Operation","Accounts":"Accounts","Active accounts":"Active","Online now":"Online now","Counters":"Counters","Save mode":"Save mode",
"Nodes":"Nodes","Active nodes":"Active","SSH enabled":"SSH enabled","Xray enabled":"Xray enabled","Sessions":"Sessions","Processor":"Processor","Memory":"Memory","Network now":"Network now","Interfaces":"Interfaces","Today":"Today","This month":"This month","Recent period":"Recent period","Update":"Refresh","Clear history":"Clear history",
"Infrastructure workspace copy":"Manage nodes, monitor system health, and inspect traffic from one workspace.","Confirmation":"Confirmation","Confirm action":"Confirm action","Confirm":"Confirm","Completed":"Completed","Action failed":"Action failed","Attention":"Attention","Information":"Information","Close":"Close",
"Loading SSH status…":"Loading SSH status…","Loading":"Loading","Could not load SSH status":"Could not load SSH status","Error":"Error","Online":"Online","SSH data updated at {time}":"SSH data updated at {time}","Delete SSH account":"Delete SSH account","The active SSH sessions for this account will be disconnected.":"The active SSH sessions for this account will be disconnected.","Delete account":"Delete account",
"Loading Xray status…":"Loading Xray status…","Could not load Xray status":"Could not load Xray status","Remove Xray client":"Remove Xray client","The client will lose access immediately after the configuration reload.":"The client will lose access immediately after the configuration reload.","Remove client":"Remove client","Client removed successfully.":"Client removed successfully.","Xray client":"Xray client",
"Loading infrastructure…":"Loading infrastructure…","Infrastructure loaded with fallback data":"Infrastructure loaded with fallback data","{count} active nodes · updated {time}":"{count} active nodes · updated {time}","Delete managed server":"Delete managed server","Delete server \"{name}\"?":"Delete server \"{name}\"?","The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.":"The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.","Delete server":"Delete server",
"Updating live status…":"Updating live status…","Live · updated {time}":"Live · updated {time}","Error loading server status":"Error loading server status","Clean live interface totals":"Clean live interface totals","Clean the live Interface totals now?":"Clean the live Interface totals now?","VnStat daily and monthly history will be preserved.":"VnStat daily and monthly history will be preserved.","Clean totals":"Clean totals","Live interface totals were cleaned.":"Live interface totals were cleaned.","Traffic counters":"Traffic counters","Clean VnStat history":"Clean VnStat history","Clean all daily and monthly traffic history?":"Clean all daily and monthly traffic history?","Live interface totals are separate and will not be reset.":"Live interface totals are separate and will not be reset.","Clean history":"Clean history","VnStat history was cleaned.":"VnStat history was cleaned.","Traffic history":"Traffic history","Clean panel log":"Clean panel log","Clean the current panel log now?":"Clean the current panel log now?","This only clears the panel log file. Automatic size-based cleanup remains enabled.":"This only clears the panel log file. Automatic size-based cleanup remains enabled.","Clean log":"Clean log",
"Delete reseller":"Delete reseller","Delete reseller \"{name}\"?":"Delete reseller \"{name}\"?","Their owned access will be removed and active SSH sessions will be disconnected.":"Their owned access will be removed and active SSH sessions will be disconnected.","Remove inbound":"Remove inbound","Remove inbound {name}?":"Remove inbound {name}?","Clients attached only to this inbound will stop connecting after the configuration is saved.":"Clients attached only to this inbound will stop connecting after the configuration is saved.",
"This endpoint already has an SSH /ssh route.":"This endpoint already has an SSH /ssh route.","Add SSH /ssh without rebuilding this inbound.":"Add SSH /ssh without rebuilding this inbound.","SSH migration attention":"SSH migration attention","Could not enable SSH /ssh":"Could not enable SSH /ssh","Select a VLESS/VMess inbound using XHTTP.":"Select a VLESS/VMess inbound using XHTTP.","Load the selected server configuration before enabling SSH.":"Load the selected server configuration before enabling SSH.","Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.":"Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.","Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.":"Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.","SSH is already enabled on /ssh by inbound {name}.":"SSH is already enabled on /ssh by inbound {name}.","Path /ssh is already used by inbound {name}. Edit that path first.":"Path /ssh is already used by inbound {name}. Edit that path first.","This inbound uses TLS but has no reusable certificate and key file paths.":"This inbound uses TLS but has no reusable certificate and key file paths.","Safe XHTTP migration":"Safe XHTTP migration","Enable SSH on /ssh":"Enable SSH on /ssh","Add SSH to the same endpoint without rebuilding {name}?":"Add SSH to the same endpoint without rebuilding {name}?","Listener":"Listener","Existing path preserved":"Existing path preserved","New SSH path":"New SSH path","Clients preserved":"Clients preserved","Enabling SSH…":"Enabling SSH…","Could not enable SSH: {error}":"Could not enable SSH: {error}","Migration was cancelled because it would alter the old inbound.":"Migration was cancelled because it would alter the old inbound.","SSH /ssh added to the draft without changing {name}.":"SSH /ssh added to the draft without changing {name}.","Enabling SSH /ssh without changing {name}…":"Enabling SSH /ssh without changing {name}…","The SSH route could not be saved. The old inbound was not changed.":"The SSH route could not be saved. The old inbound was not changed.","SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.":"SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.","SSH /ssh is active. The old inbound and all clients were preserved.":"SSH /ssh is active. The old inbound and all clients were preserved.","Configuration for this server was not loaded.":"Configuration for this server was not loaded.","Invalid visual config: {error}":"Invalid visual config: {error}","Xray could not restart.":"Xray could not restart.","Could not save configuration: {error}":"Could not save configuration: {error}","selected":"selected","this inbound":"this inbound","the old inbound":"the old inbound","untagged":"untagged"
});
Object.assign(I18N_TEXT["pt-BR"], {
"Infrastructure":"Infraestrutura","Infrastructure areas":"Áreas da infraestrutura","Infrastructure area":"Área da infraestrutura","Waiting for data":"Aguardando dados","Operation":"Operação","Accounts":"Contas","Active accounts":"Ativas","Online now":"Online agora","Counters":"Contadores","Save mode":"Salvar modo",
"Nodes":"Nós","Active nodes":"Ativos","SSH enabled":"SSH habilitado","Xray enabled":"Xray habilitado","Sessions":"Sessões","Processor":"Processador","Memory":"Memória","Network now":"Rede agora","Interfaces":"Interfaces","Today":"Hoje","This month":"Este mês","Recent period":"Período recente","Update":"Atualizar","Clear history":"Limpar histórico",
"Infrastructure workspace copy":"Gerencie nós, acompanhe a saúde do sistema e consulte tráfego em um único espaço.","Confirmation":"Confirmação","Confirm action":"Confirmar ação","Confirm":"Confirmar","Completed":"Concluído","Action failed":"Ação não concluída","Attention":"Atenção","Information":"Informação","Close":"Fechar",
"Loading SSH status…":"Carregando status SSH…","Loading":"Carregando","Could not load SSH status":"Não foi possível carregar o status SSH","Error":"Erro","Online":"Online","SSH data updated at {time}":"Dados SSH atualizados às {time}","Delete SSH account":"Excluir conta SSH","The active SSH sessions for this account will be disconnected.":"As sessões SSH ativas desta conta serão desconectadas.","Delete account":"Excluir conta",
"Loading Xray status…":"Carregando status do Xray…","Could not load Xray status":"Não foi possível carregar o status do Xray","Remove Xray client":"Remover cliente Xray","The client will lose access immediately after the configuration reload.":"O cliente perderá o acesso imediatamente após recarregar a configuração.","Remove client":"Remover cliente","Client removed successfully.":"Cliente removido com sucesso.","Xray client":"Cliente Xray",
"Loading infrastructure…":"Carregando infraestrutura…","Infrastructure loaded with fallback data":"Infraestrutura carregada com dados locais de segurança","{count} active nodes · updated {time}":"{count} nós ativos · atualizado às {time}","Delete managed server":"Excluir servidor gerenciado","Delete server \"{name}\"?":"Excluir o servidor \"{name}\"?","The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.":"O nó remoto não será apagado, mas será removido deste painel e deixará de receber ações gerenciadas.","Delete server":"Excluir servidor",
"Updating live status…":"Atualizando status ao vivo…","Live · updated {time}":"Ao vivo · atualizado às {time}","Error loading server status":"Erro ao carregar status do servidor","Clean live interface totals":"Limpar totais ao vivo das interfaces","Clean the live Interface totals now?":"Limpar agora os totais ao vivo das interfaces?","VnStat daily and monthly history will be preserved.":"O histórico diário e mensal do VnStat será preservado.","Clean totals":"Limpar totais","Live interface totals were cleaned.":"Os totais ao vivo das interfaces foram limpos.","Traffic counters":"Contadores de tráfego","Clean VnStat history":"Limpar histórico VnStat","Clean all daily and monthly traffic history?":"Limpar todo o histórico diário e mensal de tráfego?","Live interface totals are separate and will not be reset.":"Os totais ao vivo das interfaces são separados e não serão zerados.","Clean history":"Limpar histórico","VnStat history was cleaned.":"O histórico VnStat foi limpo.","Traffic history":"Histórico de tráfego","Clean panel log":"Limpar log do painel","Clean the current panel log now?":"Limpar agora o log atual do painel?","This only clears the panel log file. Automatic size-based cleanup remains enabled.":"Isso limpa somente o arquivo de log do painel. A limpeza automática por tamanho continuará ativa.","Clean log":"Limpar log",
"Delete reseller":"Excluir revendedor","Delete reseller \"{name}\"?":"Excluir o revendedor \"{name}\"?","Their owned access will be removed and active SSH sessions will be disconnected.":"Os acessos pertencentes a ele serão removidos e as sessões SSH ativas serão desconectadas.","Remove inbound":"Remover inbound","Remove inbound {name}?":"Remover o inbound {name}?","Clients attached only to this inbound will stop connecting after the configuration is saved.":"Clientes vinculados somente a este inbound deixarão de conectar após salvar a configuração.",
"This endpoint already has an SSH /ssh route.":"Este endpoint já possui uma rota SSH /ssh.","Add SSH /ssh without rebuilding this inbound.":"Adicione SSH /ssh sem recriar este inbound.","SSH migration attention":"Atenção na migração SSH","Could not enable SSH /ssh":"Não foi possível ativar SSH /ssh","Select a VLESS/VMess inbound using XHTTP.":"Selecione um inbound VLESS/VMess usando XHTTP.","Load the selected server configuration before enabling SSH.":"Carregue a configuração do servidor selecionado antes de ativar SSH.","Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.":"O SSH compartilhado exige o modo Xray nativo. Selecione Internal native emulator e salve o modo primeiro.","Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.":"O inbound {name} usa {security}, que o SSH XHTTP nativo não suporta. Use TLS ou sem TLS.","SSH is already enabled on /ssh by inbound {name}.":"SSH já está ativado em /ssh pelo inbound {name}.","Path /ssh is already used by inbound {name}. Edit that path first.":"O caminho /ssh já é usado pelo inbound {name}. Edite esse caminho primeiro.","This inbound uses TLS but has no reusable certificate and key file paths.":"Este inbound usa TLS, mas não possui caminhos reutilizáveis para certificado e chave.","Safe XHTTP migration":"Migração XHTTP segura","Enable SSH on /ssh":"Ativar SSH em /ssh","Add SSH to the same endpoint without rebuilding {name}?":"Adicionar SSH ao mesmo endpoint sem recriar {name}?","Listener":"Listener","Existing path preserved":"Path existente preservado","New SSH path":"Novo path SSH","Clients preserved":"Clientes preservados","Enabling SSH…":"Ativando SSH…","Could not enable SSH: {error}":"Não foi possível ativar SSH: {error}","Migration was cancelled because it would alter the old inbound.":"A migração foi cancelada porque alteraria o inbound antigo.","SSH /ssh added to the draft without changing {name}.":"SSH /ssh foi adicionado ao rascunho sem alterar {name}.","Enabling SSH /ssh without changing {name}…":"Ativando SSH /ssh sem alterar {name}…","The SSH route could not be saved. The old inbound was not changed.":"A rota SSH não pôde ser salva. O inbound antigo não foi alterado.","SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.":"SSH /ssh foi salvo, mas o Xray não conseguiu reiniciar. Verifique o log e use o botão Reiniciar.","SSH /ssh is active. The old inbound and all clients were preserved.":"SSH /ssh está ativo. O inbound antigo e todos os clientes foram preservados.","Configuration for this server was not loaded.":"A configuração deste servidor não foi carregada.","Invalid visual config: {error}":"Configuração visual inválida: {error}","Xray could not restart.":"O Xray não conseguiu reiniciar.","Could not save configuration: {error}":"Não foi possível salvar a configuração: {error}","selected":"selecionado","this inbound":"este inbound","the old inbound":"o inbound antigo","untagged":"sem tag"
});
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;
@@ -298,6 +320,11 @@ const fMaxConn = document.getElementById("fMaxConn");
const fExpires = document.getElementById("fExpires");
const fUp = document.getElementById("fUp");
const fDown = document.getElementById("fDown");
const sshLiveStatus = document.getElementById("sshLiveStatus");
const sshMetricState = document.getElementById("sshMetricState");
const sshMetricTotal = document.getElementById("sshMetricTotal");
const sshMetricActive = document.getElementById("sshMetricActive");
const sshMetricOnline = document.getElementById("sshMetricOnline");
// Xray
const xrayChip = document.getElementById("xrayChip");
@@ -354,6 +381,14 @@ const managedConfigStatus = document.getElementById("managedConfigStatus");
const serversStatusGrid = document.getElementById("serversStatusGrid");
const serversStatusPageStatus = document.getElementById("serversStatusPageStatus");
const serversStatusCountChip = document.getElementById("serversStatusCountChip");
const fleetLiveStatus = document.getElementById("fleetLiveStatus");
const fleetMetricNodes = document.getElementById("fleetMetricNodes");
const fleetMetricActive = document.getElementById("fleetMetricActive");
const fleetMetricSSH = document.getElementById("fleetMetricSSH");
const fleetMetricXray = document.getElementById("fleetMetricXray");
const fleetStatusOnline = document.getElementById("fleetStatusOnline");
const fleetStatusOffline = document.getElementById("fleetStatusOffline");
const fleetStatusSessions = document.getElementById("fleetStatusSessions");
// Stats
const cpuVal = document.getElementById("cpuVal");
@@ -364,6 +399,8 @@ const memDetail = document.getElementById("memDetail");
const ifaceBody = document.getElementById("ifaceBody");
const ifaceSummary = document.getElementById("ifaceSummary");
const statsUpdated = document.getElementById("statsUpdated");
const statsNetVal = document.getElementById("statsNetVal");
const statsIfaceVal = document.getElementById("statsIfaceVal");
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
const dnsttDashboardCard = document.getElementById("dnsttDashboardCard");
const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated");
@@ -381,9 +418,99 @@ const vnstatStatus = document.getElementById("vnstatStatus");
const vnTodayTotal = document.getElementById("vnTodayTotal");
const vnMonthTotal = document.getElementById("vnMonthTotal");
const vnIfaceCount = document.getElementById("vnIfaceCount");
const vnLatestPeriod = document.getElementById("vnLatestPeriod");
const reloadVnstatBtn = document.getElementById("reloadVnstatBtn");
const resetVnstatBtn = document.getElementById("resetVnstatBtn");
// Shared panel-native dialog and toast surfaces. Destructive and migration
// actions use these instead of browser confirm/alert prompts.
const panelConfirmDialog = document.getElementById("panelConfirmDialog");
const panelDialogCard = panelConfirmDialog?.querySelector(".panel-dialog-card");
const panelDialogIcon = document.getElementById("panelDialogIcon");
const panelDialogEyebrow = document.getElementById("panelDialogEyebrow");
const panelDialogTitle = document.getElementById("panelDialogTitle");
const panelDialogMessage = document.getElementById("panelDialogMessage");
const panelDialogDetail = document.getElementById("panelDialogDetail");
const panelDialogCancelBtn = document.getElementById("panelDialogCancelBtn");
const panelDialogConfirmBtn = document.getElementById("panelDialogConfirmBtn");
const panelToastStack = document.getElementById("panelToastStack");
let panelDialogResolver = null;
let panelDialogLastFocus = null;
function closePanelConfirm(accepted = false) {
if (!panelConfirmDialog || panelConfirmDialog.classList.contains("hidden")) return;
panelConfirmDialog.classList.add("hidden");
panelConfirmDialog.setAttribute("aria-hidden", "true");
document.body.classList.remove("panel-dialog-open");
const resolver = panelDialogResolver;
panelDialogResolver = null;
resolver?.(!!accepted);
panelDialogLastFocus?.focus?.();
panelDialogLastFocus = null;
}
function panelConfirm(options = {}) {
const opts = typeof options === "string" ? { message: options } : options;
if (!panelConfirmDialog || !panelDialogConfirmBtn) return Promise.resolve(false);
if (panelDialogResolver) closePanelConfirm(false);
panelDialogLastFocus = document.activeElement;
const tone = opts.tone || (opts.danger ? "danger" : "default");
panelDialogCard?.classList.toggle("is-danger", tone === "danger");
panelDialogCard?.classList.toggle("is-success", tone === "success");
if (panelDialogIcon) panelDialogIcon.textContent = opts.icon || (tone === "danger" ? "!" : tone === "success" ? "✓" : "?");
if (panelDialogEyebrow) panelDialogEyebrow.textContent = opts.eyebrow || t("Confirmation");
if (panelDialogTitle) panelDialogTitle.textContent = opts.title || t("Confirm action");
if (panelDialogMessage) panelDialogMessage.textContent = opts.message || "";
if (panelDialogDetail) {
panelDialogDetail.textContent = opts.detail || "";
panelDialogDetail.classList.toggle("hidden", !opts.detail);
}
if (panelDialogCancelBtn) panelDialogCancelBtn.textContent = opts.cancelLabel || t("Cancel");
panelDialogConfirmBtn.textContent = opts.confirmLabel || t("Confirm");
panelDialogConfirmBtn.className = tone === "danger" ? "btn btn-danger" : "btn";
panelConfirmDialog.classList.remove("hidden");
panelConfirmDialog.setAttribute("aria-hidden", "false");
document.body.classList.add("panel-dialog-open");
setTimeout(() => panelDialogConfirmBtn.focus(), 0);
return new Promise(resolve => { panelDialogResolver = resolve; });
}
function showPanelToast(message, tone = "info", title = "", duration = 4800) {
if (!panelToastStack || !message) return null;
const toast = document.createElement("article");
toast.className = `panel-toast ${tone}`;
const icon = document.createElement("span");
icon.className = "panel-toast-icon";
icon.textContent = tone === "success" ? "✓" : tone === "error" ? "!" : tone === "warning" ? "i" : "•";
const copy = document.createElement("div");
copy.className = "panel-toast-copy";
const heading = document.createElement("strong");
heading.textContent = title || (tone === "success" ? t("Completed") : tone === "error" ? t("Action failed") : tone === "warning" ? t("Attention") : t("Information"));
const body = document.createElement("p");
body.textContent = message;
copy.append(heading, body);
const close = document.createElement("button");
close.className = "panel-toast-close";
close.type = "button";
close.setAttribute("aria-label", t("Close"));
close.textContent = "×";
const remove = () => toast.remove();
close.addEventListener("click", remove);
toast.append(icon, copy, close);
panelToastStack.appendChild(toast);
if (duration > 0) setTimeout(remove, duration);
return toast;
}
panelDialogCancelBtn?.addEventListener("click", () => closePanelConfirm(false));
panelDialogConfirmBtn?.addEventListener("click", () => closePanelConfirm(true));
panelConfirmDialog?.querySelector("[data-panel-dialog-close]")?.addEventListener("click", () => closePanelConfirm(false));
document.addEventListener("keydown", event => {
if (!panelConfirmDialog || panelConfirmDialog.classList.contains("hidden")) return;
if (event.key === "Escape") closePanelConfirm(false);
if (event.key === "Enter" && document.activeElement !== panelDialogCancelBtn) closePanelConfirm(true);
});
// ─── API helper ───────────────────────────────────────────────────────────────
async function api(path, opts = {}) {
const o = Object.assign({ headers: {} }, opts);
+56 -7
View File
@@ -4,14 +4,59 @@ const tabTitles = {
ssh: ["Accounts", "SSH / SlowDNS"],
xray: ["Accounts", "Xray Users"],
resellers: ["Administration", "Resellers"],
servers: ["Administration", "Servers"],
"servers-status": ["Administration", "Servers Status"],
stats: ["Server", "Monitoring"],
vnstat: ["Traffic", "VnStat"],
servers: ["Infrastructure", "Servers"],
"servers-status": ["Infrastructure", "Servers Status"],
stats: ["Infrastructure", "Monitoring"],
vnstat: ["Infrastructure", "Traffic"],
logs: ["System", "Logs"],
bot: ["Vendas", "Bot / Telegram"],
server: ["System", "Settings"],
};
const infrastructureTabs = ["servers", "servers-status", "stats", "vnstat"];
const infrastructureNavItems = [
{ tab:"servers", icon:"▣", label:"Servers" },
{ tab:"servers-status", icon:"●", label:"Status" },
{ tab:"stats", icon:"◴", label:"Server" },
{ tab:"vnstat", icon:"⇅", label:"Traffic" },
];
function syncInfrastructureNavigation(tab = currentTab) {
if (!infrastructureTabs.includes(tab)) return;
document.querySelectorAll("[data-infra-tab]").forEach(button => button.classList.toggle("active", button.dataset.infraTab === tab));
document.querySelectorAll(".infra-section-select").forEach(select => { select.value = tab; });
}
function mountInfrastructureNavigation() {
document.querySelectorAll(".infra-nav-mount").forEach(mount => {
const shell = document.createElement("div");
shell.className = "infra-nav-shell";
const nav = document.createElement("nav");
nav.className = "infra-section-nav";
nav.setAttribute("aria-label", t("Infrastructure areas"));
const select = document.createElement("select");
select.className = "infra-section-select";
select.setAttribute("aria-label", t("Infrastructure area"));
infrastructureNavItems.forEach(item => {
const button = document.createElement("button");
button.type = "button";
button.dataset.infraTab = item.tab;
const icon = document.createElement("span");
icon.textContent = item.icon;
button.append(icon, document.createTextNode(" " + t(item.label)));
button.addEventListener("click", () => selectTab(item.tab));
nav.appendChild(button);
const option = document.createElement("option");
option.value = item.tab;
option.textContent = t(item.label);
select.appendChild(option);
});
select.addEventListener("change", () => selectTab(select.value));
shell.append(nav, select);
mount.replaceChildren(shell);
});
syncInfrastructureNavigation();
}
function updatePageHeading() {
const [eyebrow, title] = tabTitles[currentTab] || ["Dashboard", currentTab];
if (pageEyebrow) pageEyebrow.textContent = t(eyebrow);
@@ -21,12 +66,14 @@ function updatePageHeading() {
function selectTab(tab) {
currentTab = tab;
const pane = document.getElementById("tab-" + tab);
const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`);
if (!pane || !btn) return;
const navTab = infrastructureTabs.includes(tab) ? "servers" : tab;
const btn = document.querySelector(`.tab-btn[data-tab="${navTab}"]`);
if (!pane) return;
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
btn.classList.add("active");
btn?.classList.add("active");
pane.classList.add("active");
syncInfrastructureNavigation(tab);
updatePageHeading();
document.body.classList.remove("sidebar-open");
@@ -37,12 +84,14 @@ function selectTab(tab) {
if (currentRole === "superadmin") loadWizardFromConfig();
}
if (tab === "stats" && currentRole === "superadmin") loadStats();
if (tab === "vnstat" && currentRole === "superadmin") loadVnstat();
if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus();
if (tab === "resellers" && currentRole === "superadmin") loadResellers();
if (tab === "servers" && currentRole === "superadmin") loadServers();
if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab();
}
mountInfrastructureNavigation();
document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab)));
menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
+26 -2
View File
@@ -1,5 +1,6 @@
// ─── SSH Users ────────────────────────────────────────────────────────────────
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
newUserBtn.addEventListener("click", () => {
setFormCollapsed(false);
userForm.reset();
@@ -26,6 +27,11 @@ function setFormCollapsed(v) {
async function loadUsers() {
userStatus.textContent = t("Loading…");
if (sshLiveStatus) {
sshLiveStatus.textContent = t("Loading SSH status…");
sshLiveStatus.className = "workspace-live-status is-loading";
}
if (sshMetricState) sshMetricState.textContent = t("Loading");
try {
const res = await api(withServerParam("/api/users", selectedSSHServer()));
const data = await res.json();
@@ -33,6 +39,11 @@ async function loadUsers() {
userStatus.textContent = t("Loaded.");
lastReload.textContent = t("Last reload: {time}", {time: new Date().toLocaleTimeString()});
} catch (e) {
if (sshLiveStatus) {
sshLiveStatus.textContent = t("Could not load SSH status");
sshLiveStatus.className = "workspace-live-status is-error";
}
if (sshMetricState) sshMetricState.textContent = t("Error");
if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = t("Error loading users."); }
}
}
@@ -91,6 +102,14 @@ function renderUsers(users) {
});
const activeCount = Math.max(0, users.length - expiredCount);
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, online});
if (sshMetricTotal) sshMetricTotal.textContent = String(users.length);
if (sshMetricActive) sshMetricActive.textContent = String(activeCount);
if (sshMetricOnline) sshMetricOnline.textContent = String(online);
if (sshMetricState) sshMetricState.textContent = t("Online");
if (sshLiveStatus) {
sshLiveStatus.textContent = t("SSH data updated at {time}", {time:new Date().toLocaleTimeString()});
sshLiveStatus.className = "workspace-live-status is-ok";
}
}
function fillUserForm(u) {
@@ -143,7 +162,13 @@ userForm.addEventListener("submit", async e => {
});
async function deleteUser(username) {
if (!confirm(t("Delete user \"{name}\"?", {name: username}))) return;
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Delete SSH account"),
message:t("Delete user \"{name}\"?", {name: username}),
detail:t("The active SSH sessions for this account will be disconnected."),
confirmLabel:t("Delete account"),
});
if (!accepted) return;
userStatus.textContent = t("Deleting {name}…", {name: username});
try {
const res = await api(withServerParam(`/api/users/delete?username=${encodeURIComponent(username)}`, selectedSSHServer()), { method:"DELETE" });
@@ -156,4 +181,3 @@ async function deleteUser(username) {
else userStatus.textContent = t("Error deleting.");
}
}
+23 -3
View File
@@ -12,12 +12,17 @@ document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
async function loadXrayStatus() {
if (xrayChip) {
xrayChip.textContent = t("Loading Xray status…");
xrayChip.className = "workspace-live-status is-loading";
}
try {
const res = await api(withServerParam("/api/xray/status", selectedXrayServer()));
if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`);
const s = await res.json();
const run = !!s.running;
xrayChip.textContent = run ? t("running") : (s.enabled ? t("stopped") : t("disabled"));
xrayChip.className = "chip " + (run ? "green" : "red");
xrayChip.className = "workspace-live-status " + (run ? "is-ok" : (s.enabled ? "is-warn" : "is-error"));
xRunning.textContent = run ? t("Running") : t("Stopped");
xRunning.style.color = run ? "var(--success)" : "var(--danger)";
xPID.textContent = s.pid || (s.native ? "internal" : "--");
@@ -43,7 +48,15 @@ async function loadXrayStatus() {
if (dashServerStatus) dashServerStatus.textContent = (serversCache || []).length > 1 ? `${(serversCache || []).filter(n => n.is_active !== false).length} nodes configured` : (run ? t("{count} online", {count: 1}) : (s.enabled ? t("stopped") : t("disabled")));
renderDashboardCounters();
if (s.error) xStatus.textContent = t("Error: {error}", {error: s.error});
} catch (e) { if (e.message==="auth") doAuthError(); }
} catch (e) {
if (xrayChip) {
xrayChip.textContent = t("Could not load Xray status");
xrayChip.className = "workspace-live-status is-error";
}
if (xRunning) { xRunning.textContent = t("Error"); xRunning.style.color = "var(--danger)"; }
if (xStatus && e.message !== "auth") xStatus.textContent = t("Error: {error}", {error:e.message});
if (e.message==="auth") doAuthError();
}
}
async function saveXrayCoreMode() {
@@ -307,11 +320,18 @@ async function addClient(tag, formKey = tag) {
}
async function removeClient(tag, uuid) {
if (!confirm(t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}))) return;
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Remove Xray client"),
message:t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}),
detail:t("The client will lose access immediately after the configuration reload."),
confirmLabel:t("Remove client"),
});
if (!accepted) return;
try {
const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" });
if (!res.ok && res.status !== 204) throw new Error(await res.text());
xStatus.textContent = t("Client removed. Native mode hot-reloads without restart.");
showPanelToast(t("Client removed successfully."), "success", t("Xray client"));
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
} catch (e) {
if (e.message==="auth") doAuthError();
+7 -1
View File
@@ -98,7 +98,13 @@ resellerForm.addEventListener("submit", async e => {
});
async function deleteReseller(username) {
if (!confirm(`Delete reseller "${username}"? All their SSH sessions will be disconnected.`)) return;
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Delete reseller"),
message:t("Delete reseller \"{name}\"?", {name:username}),
detail:t("Their owned access will be removed and active SSH sessions will be disconnected."),
confirmLabel:t("Delete reseller"),
});
if (!accepted) return;
resellerStatus.textContent = `Deleting ${username}`;
try {
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
+42 -5
View File
@@ -19,8 +19,6 @@ document.getElementById("reloadServersBtn")?.addEventListener("click", loadServe
document.getElementById("reloadServersBtn2")?.addEventListener("click", loadServers);
document.getElementById("refreshServersBtn")?.addEventListener("click", loadServers);
document.getElementById("refreshServersStatusBtn")?.addEventListener("click", () => loadServersStatus());
document.querySelector("[data-tab='servers']")?.addEventListener("click", loadServers);
document.querySelector("[data-tab='servers-status']")?.addEventListener("click", () => loadServersStatus());
document.getElementById("clearServerFormBtn")?.addEventListener("click", clearServerForm);
document.getElementById("testServerBtn")?.addEventListener("click", testServerForm);
document.getElementById("backToServersBtn")?.addEventListener("click", () => showServerListView());
@@ -34,17 +32,39 @@ serverForm?.addEventListener("submit", async e => {
});
async function loadServers() {
let loadError = null;
if (fleetLiveStatus) {
fleetLiveStatus.textContent = t("Loading infrastructure…");
fleetLiveStatus.className = "workspace-live-status is-loading";
}
try {
const res = await api("/api/servers");
if (!res.ok) throw new Error(await res.text());
serversCache = await res.json() || [];
} catch (e) {
loadError = e;
serversCache = [{ id:"local", name:"Master node", base_url:"local", enable_ssh:true, enable_xray:true, is_active:true, is_local:true }];
if (serversStatus) serversStatus.textContent = "Error loading servers: " + e.message;
if (e.message === "auth") doAuthError();
}
renderServerSelectors();
renderServersTable();
updateFleetOverview(loadError);
}
function updateFleetOverview(error = null) {
const rows = Array.isArray(serversCache) ? serversCache.filter(Boolean) : [];
const active = rows.filter(server => server.is_active !== false);
if (fleetMetricNodes) fleetMetricNodes.textContent = String(rows.length);
if (fleetMetricActive) fleetMetricActive.textContent = String(active.length);
if (fleetMetricSSH) fleetMetricSSH.textContent = String(active.filter(server => server.enable_ssh || server.is_local).length);
if (fleetMetricXray) fleetMetricXray.textContent = String(active.filter(server => server.enable_xray || server.is_local).length);
if (fleetLiveStatus) {
fleetLiveStatus.textContent = error
? t("Infrastructure loaded with fallback data")
: t("{count} active nodes · updated {time}", {count:active.length, time:new Date().toLocaleTimeString()});
fleetLiveStatus.className = `workspace-live-status ${error ? "is-warn" : "is-ok"}`;
}
}
@@ -57,6 +77,7 @@ async function loadServersStatus(options = {}) {
if (serversStatusCountChip) serversStatusCountChip.textContent = String(nodes.length);
if (!silent) {
serversStatusPageStatus && (serversStatusPageStatus.textContent = "Loading servers...");
if (serversStatusPageStatus) serversStatusPageStatus.className = "workspace-live-status is-loading";
serversStatusGrid.innerHTML = `<div class="hint">Loading servers...</div>`;
}
const rows = await Promise.all(nodes.map(loadSingleServerStatus));
@@ -64,11 +85,23 @@ async function loadServersStatus(options = {}) {
if (serversStatusPageStatus) {
const online = rows.filter(r => r.ok).length;
serversStatusPageStatus.textContent = `${online}/${rows.length} servers online - Updated ${new Date().toLocaleTimeString()}`;
serversStatusPageStatus.className = `workspace-live-status ${online === rows.length ? "is-ok" : online > 0 ? "is-warn" : "is-error"}`;
}
const online = rows.filter(row => row.ok).length;
const sessions = rows.reduce((total, row) => {
const ssh = (Array.isArray(row.users) ? row.users : []).reduce((sum, user) => sum + Number(user.active_conns || 0), 0);
let xray = 0;
(Array.isArray(row.inbounds) ? row.inbounds : []).forEach(inbound => { xray += (inbound.clients || []).filter(client => !!client.online).length; });
return total + ssh + xray;
}, 0);
if (fleetStatusOnline) fleetStatusOnline.textContent = String(online);
if (fleetStatusOffline) fleetStatusOffline.textContent = String(Math.max(0, rows.length - online));
if (fleetStatusSessions) fleetStatusSessions.textContent = String(sessions);
} catch (e) {
if (e.message === "auth") doAuthError();
else {
serversStatusPageStatus && (serversStatusPageStatus.textContent = "Error loading server status: " + e.message);
if (serversStatusPageStatus) serversStatusPageStatus.className = "workspace-live-status is-error";
if (!silent) serversStatusGrid.innerHTML = `<div class="hint">Error loading server status.</div>`;
}
}
@@ -321,7 +354,13 @@ async function testServerForm() {
}
async function deleteServer(s) {
if (!confirm(`Delete server "${s.name || s.base_url}"?`)) return;
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Delete managed server"),
message:t("Delete server \"{name}\"?", {name:s.name || s.base_url}),
detail:t("The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions."),
confirmLabel:t("Delete server"),
});
if (!accepted) return;
try {
const res = await api(`/api/servers?id=${encodeURIComponent(s.id)}`, { method:"DELETE" });
if (!res.ok && res.status !== 204) throw new Error(await res.text());
@@ -665,5 +704,3 @@ async function loadManagedDnsttPubkey() {
else if (st) st.textContent = "Error: " + e.message;
}
}
+46 -8
View File
@@ -1,5 +1,5 @@
// ─── Stats ────────────────────────────────────────────────────────────────────
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
document.getElementById("refreshStatsBtn")?.addEventListener("click", loadStats);
async function loadDashboardStats() {
try {
@@ -115,6 +115,10 @@ async function loadDnsttHealth() {
async function loadStats() {
if (statsUpdated) {
statsUpdated.textContent = t("Updating live status…");
statsUpdated.className = "workspace-live-status is-loading";
}
try {
const res = await api("/api/stats");
if (!res.ok) throw new Error(await res.text());
@@ -139,24 +143,40 @@ async function loadStats() {
ifaceBody.appendChild(tr);
});
if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
const currentNetwork = ifaces.reduce((sum, item) => sum + Number(item.rx_mbps || 0) + Number(item.tx_mbps || 0), 0);
if (statsNetVal) statsNetVal.textContent = `${fmtMbps(currentNetwork)} Mb/s`;
if (statsIfaceVal) statsIfaceVal.textContent = String(ifaces.length);
if (statsUpdated) {
statsUpdated.textContent = t("Live · updated {time}", {time:new Date().toLocaleTimeString()});
statsUpdated.className = "workspace-live-status is-ok";
}
await loadDnsttHealth();
} catch (e) {
if (e.message==="auth") doAuthError();
else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats.";
else if (statsUpdated) {
statsUpdated.textContent = t("Error loading server status");
statsUpdated.className = "workspace-live-status is-error";
}
}
}
resetIfaceStatsBtn?.addEventListener("click", resetInterfaceStats);
async function resetInterfaceStats() {
if (!confirm("Clean the live Interface totals now? This does not delete VnStat daily/monthly history.")) return;
const accepted = await panelConfirm({
tone:"danger", icon:"⇅", title:t("Clean live interface totals"),
message:t("Clean the live Interface totals now?"),
detail:t("VnStat daily and monthly history will be preserved."),
confirmLabel:t("Clean totals"),
});
if (!accepted) return;
resetIfaceStatsBtn.disabled = true;
ifaceSummary.textContent = "Cleaning interface totals…";
try {
const res = await api("/api/stats/interfaces/reset", { method:"POST" });
if (!res.ok) throw new Error(await res.text());
ifaceSummary.textContent = "Interface totals cleaned. Auto-clean remains every 30 days.";
showPanelToast(t("Live interface totals were cleaned."), "success", t("Traffic counters"));
loadStats();
} catch (e) {
if (e.message === "auth") doAuthError();
@@ -167,7 +187,6 @@ async function resetInterfaceStats() {
}
// ─── VnStat ───────────────────────────────────────────────────────────────────
document.querySelector("[data-tab='vnstat']")?.addEventListener("click", loadVnstat);
reloadVnstatBtn?.addEventListener("click", loadVnstat);
resetVnstatBtn?.addEventListener("click", resetVnstatHistory);
@@ -188,6 +207,7 @@ function renderVnstatRows(body, rows, emptyLabel) {
async function loadVnstat() {
vnstatStatus.textContent = "Loading VnStat usage…";
vnstatStatus.className = "workspace-live-status is-loading";
try {
const res = await api("/api/vnstat?days=31&months=12");
if (!res.ok) throw new Error(await res.text());
@@ -208,21 +228,33 @@ async function loadVnstat() {
vnTodayTotal.textContent = fmtBytes(todayTotal);
vnMonthTotal.textContent = fmtBytes(monthTotal);
vnIfaceCount.textContent = String(data.interface_count ?? ifaces.size ?? 0);
if (vnLatestPeriod) vnLatestPeriod.textContent = daily[0]?.period || monthly[0]?.period || "--";
vnstatStatus.textContent = "Updated: " + new Date().toLocaleTimeString() + " · history is kept until manually cleaned.";
vnstatStatus.className = "workspace-live-status is-ok";
} catch (e) {
if (e.message === "auth") doAuthError();
else vnstatStatus.textContent = "Error loading VnStat usage: " + e.message;
else {
vnstatStatus.textContent = "Error loading VnStat usage: " + e.message;
vnstatStatus.className = "workspace-live-status is-error";
}
}
}
async function resetVnstatHistory() {
if (!confirm("Clean all VnStat daily/monthly usage history? This does not reset the live Interface totals.")) return;
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Clean VnStat history"),
message:t("Clean all daily and monthly traffic history?"),
detail:t("Live interface totals are separate and will not be reset."),
confirmLabel:t("Clean history"),
});
if (!accepted) return;
resetVnstatBtn.disabled = true;
vnstatStatus.textContent = "Cleaning VnStat history…";
try {
const res = await api("/api/vnstat/reset", { method:"POST" });
if (!res.ok) throw new Error(await res.text());
vnstatStatus.textContent = "VnStat history cleaned.";
showPanelToast(t("VnStat history was cleaned."), "success", t("Traffic history"));
loadVnstat();
} catch (e) {
if (e.message === "auth") doAuthError();
@@ -260,7 +292,13 @@ async function loadSystemLogs() {
async function clearPanelLog() {
const st = document.getElementById("systemLogStatus");
if (!confirm("Clean the panel log now? Logs are already auto-cleaned after 1 MiB.")) return;
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Clean panel log"),
message:t("Clean the current panel log now?"),
detail:t("This only clears the panel log file. Automatic size-based cleanup remains enabled."),
confirmLabel:t("Clean log"),
});
if (!accepted) return;
st.textContent = "Cleaning panel log…";
try {
const res = await api("/api/system/logs/reset", { method:"POST" });
+73 -21
View File
@@ -161,7 +161,8 @@ function renderWzInbounds() {
sshBtn.type = "button";
sshBtn.textContent = t(sshRoute ? "SSH /ssh enabled" : "Enable SSH /ssh");
sshBtn.disabled = !!sshRoute;
sshBtn.onclick = () => enableSSHForWzInbound(i);
sshBtn.title = sshRoute ? t("This endpoint already has an SSH /ssh route.") : t("Add SSH /ssh without rebuilding this inbound.");
sshBtn.onclick = () => enableSSHForWzInbound(i, true, sshBtn);
actions.appendChild(sshBtn);
}
const duplicateBtn = document.createElement("button");
@@ -178,8 +179,14 @@ function renderWzInbounds() {
delBtn.className = "btn btn-danger btn-sm";
delBtn.type = "button";
delBtn.textContent = "Remover";
delBtn.onclick = () => {
if (!confirm(`Remover o inbound ${ib.tag || "untagged"}?`)) return;
delBtn.onclick = async () => {
const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Remove inbound"),
message:t("Remove inbound {name}?", {name:ib.tag || "untagged"}),
detail:t("Clients attached only to this inbound will stop connecting after the configuration is saved."),
confirmLabel:t("Remove inbound"),
});
if (!accepted) return;
wzInbounds.splice(i,1);
if (wzEditingIndex === i) wzCancelInbound();
else if (wzEditingIndex > i) wzEditingIndex--;
@@ -346,30 +353,38 @@ function uniqueLegacySSHTag(source) {
// modifying the original inbound. The companion inherits its listen address,
// port, host, XHTTP mode, advanced transport options, TLS certificate, and key;
// only its protocol, empty SSH settings, tag, and /ssh path differ.
async function enableSSHForWzInbound(index, applyNow = true) {
function reportSSHMigration(message, tone = "error") {
const st = document.getElementById("wzStatus");
if (st) st.textContent = message;
if (typeof showPanelToast === "function") {
showPanelToast(message, tone, tone === "success" ? t("SSH /ssh enabled") : tone === "warning" ? t("SSH migration attention") : t("Could not enable SSH /ssh"));
}
}
async function enableSSHForWzInbound(index, applyNow = true, trigger = null) {
const st = document.getElementById("wzStatus");
const source = wzInbounds[index];
const selectedID = selectedXrayServer() || "local";
if (!source || !visualXHTTPSettings(source) || !["vless", "vmess"].includes(String(source.protocol || "").toLowerCase())) {
if (st) st.textContent = "Selecione um inbound VLESS/VMess com transporte XHTTP.";
reportSSHMigration(t("Select a VLESS/VMess inbound using XHTTP."));
return false;
}
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
if (st) st.textContent = "Carregue a configuração do servidor selecionado antes de ativar SSH.";
reportSSHMigration(t("Load the selected server configuration before enabling SSH."));
return false;
}
if ((document.getElementById("xCoreMode")?.value || "native") !== "native") {
if (st) st.textContent = "SSH compartilhado requer o modo Xray nativo.";
reportSSHMigration(t("Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first."));
return false;
}
const security = String(source.streamSettings?.security || "").toLowerCase();
if (security && security !== "none" && security !== "tls") {
if (st) st.textContent = `O inbound ${source.tag || "selecionado"} usa ${security}, que não é suportado pelo SSH XHTTP nativo. Use TLS ou sem TLS.`;
reportSSHMigration(t("Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.", {name:source.tag || t("selected"), security}));
return false;
}
const existingSSH = findSSHRouteForInbound(source);
if (existingSSH) {
if (st) st.textContent = `SSH já está ativado em /ssh pelo inbound ${existingSSH.tag}.`;
reportSSHMigration(t("SSH is already enabled on /ssh by inbound {name}.", {name:existingSSH.tag}), "success");
return true;
}
const pathConflict = wzInbounds.find(candidate => {
@@ -377,17 +392,37 @@ async function enableSSHForWzInbound(index, applyNow = true) {
return sameVisualEndpoint(source, candidate) && !!xh && normalizeVisualPath(xh.path) === "/ssh";
});
if (pathConflict) {
if (st) st.textContent = `O caminho /ssh já é usado pelo inbound ${pathConflict.tag || "sem tag"}. Edite esse caminho primeiro.`;
reportSSHMigration(t("Path /ssh is already used by inbound {name}. Edit that path first.", {name:pathConflict.tag || t("untagged")}));
return false;
}
if (security === "tls") {
const certificate = source.streamSettings?.tlsSettings?.certificates?.[0] || {};
if (!certificate.certificateFile || !certificate.keyFile) {
if (st) st.textContent = "O inbound usa TLS, mas não possui arquivos de certificado e chave reutilizáveis.";
reportSSHMigration(t("This inbound uses TLS but has no reusable certificate and key file paths."));
return false;
}
}
if (applyNow && !confirm(`Ativar SSH em /ssh no mesmo endpoint de ${source.tag || "este inbound"}?\n\nA configuração antiga e todos os clientes serão mantidos. O Xray será reiniciado para aplicar.`)) return false;
if (applyNow) {
const xhttp = visualXHTTPSettings(source) || {};
const clients = Array.isArray(source.settings?.clients) ? source.settings.clients.length : 0;
const accepted = await panelConfirm({
tone:"success", icon:"SSH", eyebrow:t("Safe XHTTP migration"), title:t("Enable SSH on /ssh"),
message:t("Add SSH to the same endpoint without rebuilding {name}?", {name:source.tag || t("this inbound")}),
detail:[
`${t("Listener")}: ${source.listen || "0.0.0.0"}:${source.port}`,
`${t("Existing path preserved")}: ${normalizeVisualPath(xhttp.path)}`,
`${t("New SSH path")}: /ssh`,
`${t("Clients preserved")}: ${clients}`,
`${t("Security")}: ${security === "tls" ? "TLS" : t("No TLS")}`,
].join("\n"),
confirmLabel:t("Enable SSH /ssh"),
});
if (!accepted) return false;
}
if (trigger) {
trigger.disabled = true;
trigger.textContent = t("Enabling SSH…");
}
const streamSettings = cloneJsonSafe(source.streamSettings || {});
const settingsKey = streamSettings.xhttpSettings ? "xhttpSettings" : (streamSettings.splithttpSettings ? "splithttpSettings" : "xhttpSettings");
@@ -400,28 +435,45 @@ async function enableSSHForWzInbound(index, applyNow = true) {
settings: {},
streamSettings,
};
const previousDirty = wzDirty;
const before = cloneJsonSafe(source);
wzInbounds.push(sshInbound);
try {
validateVisualInbounds(wzInbounds);
} catch (error) {
wzInbounds.pop();
if (st) st.textContent = `Não foi possível ativar SSH: ${error.message}`;
if (trigger) { trigger.disabled = false; trigger.textContent = t("Enable SSH /ssh"); }
reportSSHMigration(t("Could not enable SSH: {error}", {error:error.message}));
return false;
}
if (JSON.stringify(source) !== JSON.stringify(before)) {
wzInbounds.pop();
if (st) st.textContent = "A migração foi cancelada porque alteraria o inbound antigo.";
if (trigger) { trigger.disabled = false; trigger.textContent = t("Enable SSH /ssh"); }
reportSSHMigration(t("Migration was cancelled because it would alter the old inbound."));
return false;
}
wzDirty = true;
renderWzInbounds();
if (!applyNow) {
if (st) st.textContent = `SSH /ssh adicionado ao rascunho sem alterar ${source.tag || "o inbound antigo"}.`;
if (st) st.textContent = t("SSH /ssh added to the draft without changing {name}.", {name:source.tag || t("the old inbound")});
return true;
}
if (st) st.textContent = `Ativando SSH /ssh sem alterar ${source.tag || "o inbound antigo"}`;
return await applyWizardConfig();
if (st) st.textContent = t("Enabling SSH /ssh without changing {name}…", {name:source.tag || t("the old inbound")});
const result = await applyWizardConfig();
if (!result?.saved) {
const addedIndex = wzInbounds.indexOf(sshInbound);
if (addedIndex >= 0) wzInbounds.splice(addedIndex, 1);
wzDirty = previousDirty;
renderWzInbounds();
reportSSHMigration(result?.error || t("The SSH route could not be saved. The old inbound was not changed."));
return false;
}
if (!result.restarted) {
reportSSHMigration(t("SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log."), "warning");
return true;
}
reportSSHMigration(t("SSH /ssh is active. The old inbound and all clients were preserved."), "success");
return true;
}
function validateVisualInbounds(inbounds) {
@@ -866,7 +918,7 @@ async function applyWizardConfig() {
if (String(wzLoadedServerID || "") !== String(selectedID) || !wzLoadedConfigText) {
if (st) st.textContent = `Reloading config from ${target} before saving...`;
loadWizardFromConfig();
return false;
return { saved:false, restarted:false, error:t("Configuration for this server was not loaded.") };
}
let cfg;
@@ -874,7 +926,7 @@ async function applyWizardConfig() {
cfg = buildConfigFromVisualEditor();
} catch(e) {
if (st) st.textContent = `Invalid visual config: ${e.message}`;
return false;
return { saved:false, restarted:false, error:t("Invalid visual config: {error}", {error:e.message}) };
}
if (st) st.textContent = `Saving config to ${target}...`;
@@ -892,10 +944,10 @@ async function applyWizardConfig() {
? `Config saved on ${target} and Xray restarted.`
: `Config saved on ${target}, but Xray could not restart. Check Xray logs before editing again.`;
setTimeout(() => { loadXrayStatus(); loadInbounds({ force: true }); }, 700);
return !!restarted;
return { saved:true, restarted:!!restarted, error:restarted ? "" : t("Xray could not restart.") };
} catch (e) {
if (e.message==="auth") doAuthError();
else if (st) st.textContent = "Error: " + e.message;
return false;
return { saved:false, restarted:false, error:t("Could not save configuration: {error}", {error:e.message}) };
}
}
+18 -4
View File
@@ -320,7 +320,8 @@ async function botSavePlan(event) {
}
async function botDeletePlan(id) {
if (!confirm("Excluir este plano? Esta ação não pode ser desfeita.")) return;
const accepted = await panelConfirm({ tone:"danger", icon:"×", title:"Excluir plano", message:"Excluir este plano?", detail:"Esta ação não pode ser desfeita.", confirmLabel:"Excluir plano" });
if (!accepted) return;
try {
await botRequest(`/api/bot/plans?id=${encodeURIComponent(id)}`, { method: "DELETE" });
await loadBotPlans();
@@ -396,7 +397,8 @@ async function botSavePkg(event) {
}
async function botDeletePkg(id) {
if (!confirm("Excluir este pacote de créditos?")) return;
const accepted = await panelConfirm({ tone:"danger", icon:"×", title:"Excluir pacote", message:"Excluir este pacote de créditos?", confirmLabel:"Excluir pacote" });
if (!accepted) return;
try {
await botRequest(`/api/bot/credit-packages?id=${encodeURIComponent(id)}`, { method: "DELETE" });
await loadBotPkgs();
@@ -491,7 +493,13 @@ async function botSaveUserAction(event) {
async function botToggleBlock(user) {
const isBlocked = user.Role === "blocked";
if (!confirm(isBlocked ? "Desbloquear este cliente?" : "Bloquear este cliente no bot?")) return;
const accepted = await panelConfirm({
tone:isBlocked ? "default" : "danger", icon:isBlocked ? "✓" : "!",
title:isBlocked ? "Desbloquear cliente" : "Bloquear cliente",
message:isBlocked ? "Desbloquear este cliente?" : "Bloquear este cliente no bot?",
confirmLabel:isBlocked ? "Desbloquear" : "Bloquear",
});
if (!accepted) return;
try {
await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: user.TelegramID, action: isBlocked ? "unblock" : "block" }) });
await loadBotUsers();
@@ -551,7 +559,13 @@ async function botReprocess(id) {
}
async function botRefund(id) {
if (!confirm(`Isso apenas marca o pagamento #${id} como estornado no painel. Não envia um estorno financeiro ao Mercado Pago. Continuar?`)) return;
const accepted = await panelConfirm({
tone:"danger", icon:"!", title:"Marcar como estornado",
message:`Marcar o pagamento #${id} como estornado no painel?`,
detail:"Esta ação não envia um estorno financeiro ao Mercado Pago; ela altera somente o status interno.",
confirmLabel:"Marcar estornado",
});
if (!accepted) return;
try {
await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) });
await loadBotTxns();