refactor(web): separate Hysteria settings into dedicated page
This commit is contained in:
271
core/scripts/webpanel/assets/js/hysteria_settings.js
Normal file
271
core/scripts/webpanel/assets/js/hysteria_settings.js
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
$(document).ready(function () {
|
||||||
|
const contentSection = document.querySelector('.content');
|
||||||
|
|
||||||
|
const API_URLS = {
|
||||||
|
getPort: contentSection.dataset.getPortUrl,
|
||||||
|
getSni: contentSection.dataset.getSniUrl,
|
||||||
|
checkObfs: contentSection.dataset.checkObfsUrl,
|
||||||
|
enableObfs: contentSection.dataset.enableObfsUrl,
|
||||||
|
disableObfs: contentSection.dataset.disableObfsUrl,
|
||||||
|
setPortTemplate: contentSection.dataset.setPortUrlTemplate,
|
||||||
|
setSniTemplate: contentSection.dataset.setSniUrlTemplate,
|
||||||
|
updateGeoTemplate: contentSection.dataset.updateGeoUrlTemplate
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValidDomain(domain) {
|
||||||
|
if (!domain) return false;
|
||||||
|
const lowerDomain = domain.toLowerCase();
|
||||||
|
return !lowerDomain.startsWith("http://") && !lowerDomain.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPort(port) {
|
||||||
|
if (!port) return false;
|
||||||
|
return /^[0-9]+$/.test(port) && parseInt(port) > 0 && parseInt(port) <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmAction(actionName, callback) {
|
||||||
|
Swal.fire({
|
||||||
|
title: `Are you sure?`,
|
||||||
|
text: `Do you really want to ${actionName}?`,
|
||||||
|
icon: "warning",
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonColor: "#3085d6",
|
||||||
|
cancelButtonColor: "#d33",
|
||||||
|
confirmButtonText: "Yes, proceed!",
|
||||||
|
cancelButtonText: "Cancel"
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendRequest(url, type, data, successMessage, buttonSelector, showReload = true, postSuccessCallback = null) {
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
type: type,
|
||||||
|
contentType: "application/json",
|
||||||
|
data: data ? JSON.stringify(data) : null,
|
||||||
|
beforeSend: function() {
|
||||||
|
if (buttonSelector) {
|
||||||
|
$(buttonSelector).prop('disabled', true);
|
||||||
|
$(buttonSelector + ' .spinner-border').show();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: function (response) {
|
||||||
|
Swal.fire("Success!", successMessage, "success").then(() => {
|
||||||
|
if (showReload) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
if (postSuccessCallback) {
|
||||||
|
postSuccessCallback(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: function (xhr, status, error) {
|
||||||
|
let errorMessage = "An unexpected error occurred.";
|
||||||
|
if (xhr.responseJSON && xhr.responseJSON.detail) {
|
||||||
|
const detail = xhr.responseJSON.detail;
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
errorMessage = detail.map(err => `Error in '${err.loc[1]}': ${err.msg}`).join('\n');
|
||||||
|
} else if (typeof detail === 'string') {
|
||||||
|
let userMessage = detail;
|
||||||
|
const failMarker = 'failed with exit code';
|
||||||
|
const markerIndex = detail.indexOf(failMarker);
|
||||||
|
if (markerIndex > -1) {
|
||||||
|
const colonIndex = detail.indexOf(':', markerIndex);
|
||||||
|
if (colonIndex > -1) {
|
||||||
|
userMessage = detail.substring(colonIndex + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errorMessage = userMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Swal.fire("Error!", errorMessage, "error");
|
||||||
|
console.error("AJAX Error:", status, error, xhr.responseText);
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
if (buttonSelector) {
|
||||||
|
$(buttonSelector).prop('disabled', false);
|
||||||
|
$(buttonSelector + ' .spinner-border').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(formId) {
|
||||||
|
let isValid = true;
|
||||||
|
$(`#${formId} .form-control:visible`).each(function () {
|
||||||
|
const input = $(this);
|
||||||
|
const id = input.attr('id');
|
||||||
|
let fieldValid = true;
|
||||||
|
|
||||||
|
if (id === 'sni_domain') {
|
||||||
|
fieldValid = isValidDomain(input.val());
|
||||||
|
} else if (id === 'hysteria_port') {
|
||||||
|
fieldValid = isValidPort(input.val());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fieldValid) {
|
||||||
|
input.addClass('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
input.removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initUI() {
|
||||||
|
$.ajax({
|
||||||
|
url: API_URLS.getPort,
|
||||||
|
type: "GET",
|
||||||
|
success: function (data) {
|
||||||
|
$("#hysteria_port").val(data.port || "");
|
||||||
|
},
|
||||||
|
error: function (xhr, status, error) {
|
||||||
|
console.error("Failed to fetch port:", error, xhr.responseText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: API_URLS.getSni,
|
||||||
|
type: "GET",
|
||||||
|
success: function (data) {
|
||||||
|
$("#sni_domain").val(data.sni || "");
|
||||||
|
},
|
||||||
|
error: function (xhr, status, error) {
|
||||||
|
console.error("Failed to fetch SNI domain:", error, xhr.responseText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchObfsStatus() {
|
||||||
|
$.ajax({
|
||||||
|
url: API_URLS.checkObfs,
|
||||||
|
type: "GET",
|
||||||
|
success: function (data) {
|
||||||
|
updateObfsUI(data.obfs);
|
||||||
|
},
|
||||||
|
error: function (xhr, status, error) {
|
||||||
|
$("#obfs_status_message").html('<span class="text-danger">Failed to fetch OBFS status.</span>');
|
||||||
|
console.error("Failed to fetch OBFS status:", error, xhr.responseText);
|
||||||
|
$("#obfs_enable_btn").hide();
|
||||||
|
$("#obfs_disable_btn").hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateObfsUI(statusMessage) {
|
||||||
|
$("#obfs_status_message").text(statusMessage);
|
||||||
|
if (statusMessage === "OBFS is active.") {
|
||||||
|
$("#obfs_enable_btn").hide();
|
||||||
|
$("#obfs_disable_btn").show();
|
||||||
|
$("#obfs_status_container").removeClass("border-danger border-warning alert-danger alert-warning").addClass("border-success alert-success");
|
||||||
|
} else if (statusMessage === "OBFS is not active.") {
|
||||||
|
$("#obfs_enable_btn").show();
|
||||||
|
$("#obfs_disable_btn").hide();
|
||||||
|
$("#obfs_status_container").removeClass("border-success border-danger alert-success alert-danger").addClass("border-warning alert-warning");
|
||||||
|
} else {
|
||||||
|
$("#obfs_enable_btn").hide();
|
||||||
|
$("#obfs_disable_btn").hide();
|
||||||
|
$("#obfs_status_container").removeClass("border-success border-warning alert-success alert-warning").addClass("border-danger alert-danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableObfs() {
|
||||||
|
confirmAction("enable OBFS", function () {
|
||||||
|
sendRequest(
|
||||||
|
API_URLS.enableObfs,
|
||||||
|
"GET",
|
||||||
|
null,
|
||||||
|
"OBFS enabled successfully!",
|
||||||
|
"#obfs_enable_btn",
|
||||||
|
false,
|
||||||
|
fetchObfsStatus
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableObfs() {
|
||||||
|
confirmAction("disable OBFS", function () {
|
||||||
|
sendRequest(
|
||||||
|
API_URLS.disableObfs,
|
||||||
|
"GET",
|
||||||
|
null,
|
||||||
|
"OBFS disabled successfully!",
|
||||||
|
"#obfs_disable_btn",
|
||||||
|
false,
|
||||||
|
fetchObfsStatus
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePort() {
|
||||||
|
if (!validateForm('port_form')) return;
|
||||||
|
const port = $("#hysteria_port").val();
|
||||||
|
const url = API_URLS.setPortTemplate.replace("PORT_PLACEHOLDER", port);
|
||||||
|
confirmAction("change the port", function () {
|
||||||
|
sendRequest(url, "GET", null, "Port changed successfully!", "#port_change");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSNI() {
|
||||||
|
if (!validateForm('sni_form')) return;
|
||||||
|
const domain = $("#sni_domain").val();
|
||||||
|
const url = API_URLS.setSniTemplate.replace("SNI_PLACEHOLDER", domain);
|
||||||
|
confirmAction("change the SNI", function () {
|
||||||
|
sendRequest(url, "GET", null, "SNI changed successfully!", "#sni_change");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGeo(country) {
|
||||||
|
const countryName = country.charAt(0).toUpperCase() + country.slice(1);
|
||||||
|
const buttonId = `#geo_update_${country}`;
|
||||||
|
const url = API_URLS.updateGeoTemplate.replace('COUNTRY_PLACEHOLDER', country);
|
||||||
|
|
||||||
|
confirmAction(`update the Geo files for ${countryName}`, function () {
|
||||||
|
sendRequest(
|
||||||
|
url,
|
||||||
|
"GET",
|
||||||
|
null,
|
||||||
|
`Geo files for ${countryName} updated successfully!`,
|
||||||
|
buttonId,
|
||||||
|
false,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initUI();
|
||||||
|
fetchObfsStatus();
|
||||||
|
|
||||||
|
$("#port_change").on("click", changePort);
|
||||||
|
$("#sni_change").on("click", changeSNI);
|
||||||
|
$("#obfs_enable_btn").on("click", enableObfs);
|
||||||
|
$("#obfs_disable_btn").on("click", disableObfs);
|
||||||
|
$("#geo_update_iran").on("click", function() { updateGeo('iran'); });
|
||||||
|
$("#geo_update_china").on("click", function() { updateGeo('china'); });
|
||||||
|
$("#geo_update_russia").on("click", function() { updateGeo('russia'); });
|
||||||
|
|
||||||
|
$('#sni_domain').on('input', function () {
|
||||||
|
if (isValidDomain($(this).val())) {
|
||||||
|
$(this).removeClass('is-invalid');
|
||||||
|
} else if ($(this).val().trim() !== "") {
|
||||||
|
$(this).addClass('is-invalid');
|
||||||
|
} else {
|
||||||
|
$(this).removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#hysteria_port').on('input', function () {
|
||||||
|
if (isValidPort($(this).val())) {
|
||||||
|
$(this).removeClass('is-invalid');
|
||||||
|
} else if ($(this).val().trim() !== "") {
|
||||||
|
$(this).addClass('is-invalid');
|
||||||
|
} else {
|
||||||
|
$(this).removeClass('is-invalid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,8 +4,6 @@ $(document).ready(function () {
|
|||||||
const API_URLS = {
|
const API_URLS = {
|
||||||
serverServicesStatus: contentSection.dataset.serverServicesStatusUrl,
|
serverServicesStatus: contentSection.dataset.serverServicesStatusUrl,
|
||||||
getIp: contentSection.dataset.getIpUrl,
|
getIp: contentSection.dataset.getIpUrl,
|
||||||
getPort: contentSection.dataset.getPortUrl,
|
|
||||||
getSni: contentSection.dataset.getSniUrl,
|
|
||||||
getAllNodes: contentSection.dataset.getAllNodesUrl,
|
getAllNodes: contentSection.dataset.getAllNodesUrl,
|
||||||
addNode: contentSection.dataset.addNodeUrl,
|
addNode: contentSection.dataset.addNodeUrl,
|
||||||
deleteNode: contentSection.dataset.deleteNodeUrl,
|
deleteNode: contentSection.dataset.deleteNodeUrl,
|
||||||
@ -19,16 +17,11 @@ $(document).ready(function () {
|
|||||||
setupDecoy: contentSection.dataset.setupDecoyUrl,
|
setupDecoy: contentSection.dataset.setupDecoyUrl,
|
||||||
stopDecoy: contentSection.dataset.stopDecoyUrl,
|
stopDecoy: contentSection.dataset.stopDecoyUrl,
|
||||||
getDecoyStatus: contentSection.dataset.getDecoyStatusUrl,
|
getDecoyStatus: contentSection.dataset.getDecoyStatusUrl,
|
||||||
checkObfs: contentSection.dataset.checkObfsUrl,
|
|
||||||
enableObfs: contentSection.dataset.enableObfsUrl,
|
|
||||||
disableObfs: contentSection.dataset.disableObfsUrl,
|
|
||||||
telegramStart: contentSection.dataset.telegramStartUrl,
|
telegramStart: contentSection.dataset.telegramStartUrl,
|
||||||
telegramStop: contentSection.dataset.telegramStopUrl,
|
telegramStop: contentSection.dataset.telegramStopUrl,
|
||||||
telegramSetInterval: contentSection.dataset.telegramSetIntervalUrl,
|
telegramSetInterval: contentSection.dataset.telegramSetIntervalUrl,
|
||||||
normalSubStart: contentSection.dataset.normalSubStartUrl,
|
normalSubStart: contentSection.dataset.normalSubStartUrl,
|
||||||
normalSubStop: contentSection.dataset.normalSubStopUrl,
|
normalSubStop: contentSection.dataset.normalSubStopUrl,
|
||||||
setPortTemplate: contentSection.dataset.setPortUrlTemplate,
|
|
||||||
setSniTemplate: contentSection.dataset.setSniUrlTemplate,
|
|
||||||
editIp: contentSection.dataset.editIpUrl,
|
editIp: contentSection.dataset.editIpUrl,
|
||||||
backup: contentSection.dataset.backupUrl,
|
backup: contentSection.dataset.backupUrl,
|
||||||
restore: contentSection.dataset.restoreUrl,
|
restore: contentSection.dataset.restoreUrl,
|
||||||
@ -36,7 +29,6 @@ $(document).ready(function () {
|
|||||||
stopIpLimit: contentSection.dataset.stopIpLimitUrl,
|
stopIpLimit: contentSection.dataset.stopIpLimitUrl,
|
||||||
configIpLimit: contentSection.dataset.configIpLimitUrl,
|
configIpLimit: contentSection.dataset.configIpLimitUrl,
|
||||||
statusWarp: contentSection.dataset.statusWarpUrl,
|
statusWarp: contentSection.dataset.statusWarpUrl,
|
||||||
updateGeoTemplate: contentSection.dataset.updateGeoUrlTemplate,
|
|
||||||
installWarp: contentSection.dataset.installWarpUrl,
|
installWarp: contentSection.dataset.installWarpUrl,
|
||||||
uninstallWarp: contentSection.dataset.uninstallWarpUrl,
|
uninstallWarp: contentSection.dataset.uninstallWarpUrl,
|
||||||
configureWarp: contentSection.dataset.configureWarpUrl
|
configureWarp: contentSection.dataset.configureWarpUrl
|
||||||
@ -44,7 +36,6 @@ $(document).ready(function () {
|
|||||||
|
|
||||||
initUI();
|
initUI();
|
||||||
fetchDecoyStatus();
|
fetchDecoyStatus();
|
||||||
fetchObfsStatus();
|
|
||||||
fetchNodes();
|
fetchNodes();
|
||||||
fetchExtraConfigs();
|
fetchExtraConfigs();
|
||||||
|
|
||||||
@ -185,9 +176,9 @@ $(document).ready(function () {
|
|||||||
const id = input.attr('id');
|
const id = input.attr('id');
|
||||||
let fieldValid = true;
|
let fieldValid = true;
|
||||||
|
|
||||||
if (id === 'normal_domain' || id === 'sni_domain' || id === 'decoy_domain') {
|
if (id === 'normal_domain' || id === 'decoy_domain') {
|
||||||
fieldValid = isValidDomain(input.val());
|
fieldValid = isValidDomain(input.val());
|
||||||
} else if (id === 'normal_port' || id === 'hysteria_port') {
|
} else if (id === 'normal_port') {
|
||||||
fieldValid = isValidPort(input.val());
|
fieldValid = isValidPort(input.val());
|
||||||
} else if (id === 'normal_subpath_input') {
|
} else if (id === 'normal_subpath_input') {
|
||||||
fieldValid = isValidSubPath(input.val());
|
fieldValid = isValidSubPath(input.val());
|
||||||
@ -247,28 +238,6 @@ $(document).ready(function () {
|
|||||||
console.error("Failed to fetch IP addresses:", error, xhr.responseText);
|
console.error("Failed to fetch IP addresses:", error, xhr.responseText);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: API_URLS.getPort,
|
|
||||||
type: "GET",
|
|
||||||
success: function (data) {
|
|
||||||
$("#hysteria_port").val(data.port || "");
|
|
||||||
},
|
|
||||||
error: function (xhr, status, error) {
|
|
||||||
console.error("Failed to fetch port:", error, xhr.responseText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: API_URLS.getSni,
|
|
||||||
type: "GET",
|
|
||||||
success: function (data) {
|
|
||||||
$("#sni_domain").val(data.sni || "");
|
|
||||||
},
|
|
||||||
error: function (xhr, status, error) {
|
|
||||||
console.error("Failed to fetch SNI domain:", error, xhr.responseText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchNodes() {
|
function fetchNodes() {
|
||||||
@ -669,67 +638,6 @@ $(document).ready(function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchObfsStatus() {
|
|
||||||
$.ajax({
|
|
||||||
url: API_URLS.checkObfs,
|
|
||||||
type: "GET",
|
|
||||||
success: function (data) {
|
|
||||||
updateObfsUI(data.obfs);
|
|
||||||
},
|
|
||||||
error: function (xhr, status, error) {
|
|
||||||
$("#obfs_status_message").html('<span class="text-danger">Failed to fetch OBFS status.</span>');
|
|
||||||
console.error("Failed to fetch OBFS status:", error, xhr.responseText);
|
|
||||||
$("#obfs_enable_btn").hide();
|
|
||||||
$("#obfs_disable_btn").hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateObfsUI(statusMessage) {
|
|
||||||
$("#obfs_status_message").text(statusMessage);
|
|
||||||
if (statusMessage === "OBFS is active.") {
|
|
||||||
$("#obfs_enable_btn").hide();
|
|
||||||
$("#obfs_disable_btn").show();
|
|
||||||
$("#obfs_status_container").removeClass("border-danger border-warning alert-danger alert-warning").addClass("border-success alert-success");
|
|
||||||
} else if (statusMessage === "OBFS is not active.") {
|
|
||||||
$("#obfs_enable_btn").show();
|
|
||||||
$("#obfs_disable_btn").hide();
|
|
||||||
$("#obfs_status_container").removeClass("border-success border-danger alert-success alert-danger").addClass("border-warning alert-warning");
|
|
||||||
} else {
|
|
||||||
$("#obfs_enable_btn").hide();
|
|
||||||
$("#obfs_disable_btn").hide();
|
|
||||||
$("#obfs_status_container").removeClass("border-success border-warning alert-success alert-warning").addClass("border-danger alert-danger");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function enableObfs() {
|
|
||||||
confirmAction("enable OBFS", function () {
|
|
||||||
sendRequest(
|
|
||||||
API_URLS.enableObfs,
|
|
||||||
"GET",
|
|
||||||
null,
|
|
||||||
"OBFS enabled successfully!",
|
|
||||||
"#obfs_enable_btn",
|
|
||||||
false,
|
|
||||||
fetchObfsStatus
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableObfs() {
|
|
||||||
confirmAction("disable OBFS", function () {
|
|
||||||
sendRequest(
|
|
||||||
API_URLS.disableObfs,
|
|
||||||
"GET",
|
|
||||||
null,
|
|
||||||
"OBFS disabled successfully!",
|
|
||||||
"#obfs_disable_btn",
|
|
||||||
false,
|
|
||||||
fetchObfsStatus
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startTelegram() {
|
function startTelegram() {
|
||||||
if (!validateForm('telegram_form')) return;
|
if (!validateForm('telegram_form')) return;
|
||||||
const apiToken = $("#telegram_api_token").val();
|
const apiToken = $("#telegram_api_token").val();
|
||||||
@ -821,24 +729,6 @@ $(document).ready(function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePort() {
|
|
||||||
if (!validateForm('port_form')) return;
|
|
||||||
const port = $("#hysteria_port").val();
|
|
||||||
const url = API_URLS.setPortTemplate.replace("PORT_PLACEHOLDER", port);
|
|
||||||
confirmAction("change the port", function () {
|
|
||||||
sendRequest(url, "GET", null, "Port changed successfully!", "#port_change");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeSNI() {
|
|
||||||
if (!validateForm('sni_form')) return;
|
|
||||||
const domain = $("#sni_domain").val();
|
|
||||||
const url = API_URLS.setSniTemplate.replace("SNI_PLACEHOLDER", domain);
|
|
||||||
confirmAction("change the SNI", function () {
|
|
||||||
sendRequest(url, "GET", null, "SNI changed successfully!", "#sni_change");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveIP() {
|
function saveIP() {
|
||||||
if (!validateForm('change_ip_form')) return;
|
if (!validateForm('change_ip_form')) return;
|
||||||
const ipv4 = $("#ipv4").val().trim() || null;
|
const ipv4 = $("#ipv4").val().trim() || null;
|
||||||
@ -1007,24 +897,6 @@ $(document).ready(function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGeo(country) {
|
|
||||||
const countryName = country.charAt(0).toUpperCase() + country.slice(1);
|
|
||||||
const buttonId = `#geo_update_${country}`;
|
|
||||||
const url = API_URLS.updateGeoTemplate.replace('COUNTRY_PLACEHOLDER', country);
|
|
||||||
|
|
||||||
confirmAction(`update the Geo files for ${countryName}`, function () {
|
|
||||||
sendRequest(
|
|
||||||
url,
|
|
||||||
"GET",
|
|
||||||
null,
|
|
||||||
`Geo files for ${countryName} updated successfully!`,
|
|
||||||
buttonId,
|
|
||||||
false,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#warp_start_btn").on("click", function() {
|
$("#warp_start_btn").on("click", function() {
|
||||||
confirmAction("install and start WARP", function () {
|
confirmAction("install and start WARP", function () {
|
||||||
sendRequest(
|
sendRequest(
|
||||||
@ -1077,8 +949,6 @@ $(document).ready(function () {
|
|||||||
$("#normal_start").on("click", startNormal);
|
$("#normal_start").on("click", startNormal);
|
||||||
$("#normal_stop").on("click", stopNormal);
|
$("#normal_stop").on("click", stopNormal);
|
||||||
$("#normal_subpath_save_btn").on("click", editNormalSubPath);
|
$("#normal_subpath_save_btn").on("click", editNormalSubPath);
|
||||||
$("#port_change").on("click", changePort);
|
|
||||||
$("#sni_change").on("click", changeSNI);
|
|
||||||
$("#ip_change").on("click", saveIP);
|
$("#ip_change").on("click", saveIP);
|
||||||
$("#download_backup").on("click", downloadBackup);
|
$("#download_backup").on("click", downloadBackup);
|
||||||
$("#upload_backup").on("click", uploadBackup);
|
$("#upload_backup").on("click", uploadBackup);
|
||||||
@ -1087,8 +957,6 @@ $(document).ready(function () {
|
|||||||
$("#ip_limit_change_config").on("click", configIPLimit);
|
$("#ip_limit_change_config").on("click", configIPLimit);
|
||||||
$("#decoy_setup").on("click", setupDecoy);
|
$("#decoy_setup").on("click", setupDecoy);
|
||||||
$("#decoy_stop").on("click", stopDecoy);
|
$("#decoy_stop").on("click", stopDecoy);
|
||||||
$("#obfs_enable_btn").on("click", enableObfs);
|
|
||||||
$("#obfs_disable_btn").on("click", disableObfs);
|
|
||||||
$("#add_node_btn").on("click", addNode);
|
$("#add_node_btn").on("click", addNode);
|
||||||
$("#nodes_table").on("click", ".delete-node-btn", function() {
|
$("#nodes_table").on("click", ".delete-node-btn", function() {
|
||||||
const nodeName = $(this).data("name");
|
const nodeName = $(this).data("name");
|
||||||
@ -1099,11 +967,8 @@ $(document).ready(function () {
|
|||||||
const configName = $(this).data("name");
|
const configName = $(this).data("name");
|
||||||
deleteExtraConfig(configName);
|
deleteExtraConfig(configName);
|
||||||
});
|
});
|
||||||
$("#geo_update_iran").on("click", function() { updateGeo('iran'); });
|
|
||||||
$("#geo_update_china").on("click", function() { updateGeo('china'); });
|
|
||||||
$("#geo_update_russia").on("click", function() { updateGeo('russia'); });
|
|
||||||
|
|
||||||
$('#normal_domain, #sni_domain, #decoy_domain').on('input', function () {
|
$('#normal_domain, #decoy_domain').on('input', function () {
|
||||||
if (isValidDomain($(this).val())) {
|
if (isValidDomain($(this).val())) {
|
||||||
$(this).removeClass('is-invalid');
|
$(this).removeClass('is-invalid');
|
||||||
} else if ($(this).val().trim() !== "") {
|
} else if ($(this).val().trim() !== "") {
|
||||||
@ -1113,7 +978,7 @@ $(document).ready(function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#normal_port, #hysteria_port').on('input', function () {
|
$('#normal_port').on('input', function () {
|
||||||
if (isValidPort($(this).val())) {
|
if (isValidPort($(this).val())) {
|
||||||
$(this).removeClass('is-invalid');
|
$(this).removeClass('is-invalid');
|
||||||
} else if ($(this).val().trim() !== "") {
|
} else if ($(this).val().trim() !== "") {
|
||||||
|
|||||||
@ -13,3 +13,8 @@ async def settings(request: Request, templates: Jinja2Templates = Depends(get_te
|
|||||||
@router.get('/config')
|
@router.get('/config')
|
||||||
async def config(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
async def config(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
||||||
return templates.TemplateResponse('config.html', {'request': request})
|
return templates.TemplateResponse('config.html', {'request': request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/hysteria')
|
||||||
|
async def hysteria_settings(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
||||||
|
return templates.TemplateResponse('hysteria_settings.html', {'request': request})
|
||||||
@ -135,6 +135,12 @@
|
|||||||
<p>Settings</p>
|
<p>Settings</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{{ url_for('hysteria_settings') }}" class="nav-link {% if request.path == url_for('hysteria_settings') %}active{% endif %}">
|
||||||
|
<i class="nav-icon fas fa-bolt"></i>
|
||||||
|
<p>Hysteria</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="{{ url_for('config') }}" class="nav-link {% if request.path == url_for('config') %}active{% endif %}">
|
<a href="{{ url_for('config') }}" class="nav-link {% if request.path == url_for('config') %}active{% endif %}">
|
||||||
<i class="nav-icon fas fa-cog"></i>
|
<i class="nav-icon fas fa-cog"></i>
|
||||||
|
|||||||
118
core/scripts/webpanel/templates/hysteria_settings.html
Normal file
118
core/scripts/webpanel/templates/hysteria_settings.html
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Hysteria Settings{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class='content-header'>
|
||||||
|
<div class='container-fluid'>
|
||||||
|
<div class='row mb-2'>
|
||||||
|
<div class='col-sm-6'>
|
||||||
|
<h1 class='m-0'>Hysteria Settings</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='content'
|
||||||
|
data-get-port-url="{{ url_for('get_port_api') }}"
|
||||||
|
data-get-sni-url="{{ url_for('get_sni_api') }}"
|
||||||
|
data-check-obfs-url="{{ url_for('check_obfs') }}"
|
||||||
|
data-enable-obfs-url="{{ url_for('enable_obfs') }}"
|
||||||
|
data-disable-obfs-url="{{ url_for('disable_obfs') }}"
|
||||||
|
data-set-port-url-template="{{ url_for('set_port_api', port='PORT_PLACEHOLDER') }}"
|
||||||
|
data-set-sni-url-template="{{ url_for('set_sni_api', sni='SNI_PLACEHOLDER') }}"
|
||||||
|
data-update-geo-url-template="{{ url_for('update_geo', country='COUNTRY_PLACEHOLDER') }}"
|
||||||
|
>
|
||||||
|
<div class='container-fluid'>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card card-outline card-primary h-100">
|
||||||
|
<div class="card-header"><h3 class="card-title"><i class="fas fa-server"></i> Change Port</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="port_form">
|
||||||
|
<div class='form-group'>
|
||||||
|
<label for='hysteria_port'>Port:</label>
|
||||||
|
<input type='text' class='form-control' id='hysteria_port'
|
||||||
|
placeholder='Enter Port'>
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
Please enter a valid port number.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="port_change" type='button' class='btn btn-primary'>Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card card-outline card-primary h-100">
|
||||||
|
<div class="card-header"><h3 class="card-title"><i class="fas fa-shield-alt"></i> Change SNI</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="sni_form">
|
||||||
|
<div class='form-group'>
|
||||||
|
<label for='sni_domain'>Domain:</label>
|
||||||
|
<input type='text' class='form-control' id='sni_domain'
|
||||||
|
placeholder='Enter Domain'>
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
Please enter a valid domain (without http:// or https://).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="sni_change" type='button' class='btn btn-primary'>Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card card-outline card-secondary h-100">
|
||||||
|
<div class="card-header"><h3 class="card-title"><i class="fas fa-user-secret"></i> OBFS</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<h5>OBFS Status</h5>
|
||||||
|
<div id="obfs_status_container" class="p-3 border rounded">
|
||||||
|
<span id="obfs_status_message">Loading OBFS status...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="obfs_enable_btn" type='button' class='btn btn-success' style="display: none;">
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||||
|
Enable OBFS
|
||||||
|
</button>
|
||||||
|
<button id="obfs_disable_btn" type='button' class='btn btn-danger' style="display: none;">
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||||
|
Disable OBFS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card card-outline card-info h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title"><i class="fas fa-globe-americas"></i> Update GeoIP & GeoSite Files</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Update GeoIP and GeoSite database files for specific countries. This is useful for routing rules.</p>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<button id="geo_update_iran" type='button' class='btn btn-secondary mb-2'>
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||||
|
Update for Iran
|
||||||
|
</button>
|
||||||
|
<button id="geo_update_china" type='button' class='btn btn-secondary mb-2'>
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||||
|
Update for China
|
||||||
|
</button>
|
||||||
|
<button id="geo_update_russia" type='button' class='btn btn-secondary'>
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||||
|
Update for Russia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||||
|
<script src="{{ url_for('assets', path='js/hysteria_settings.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
@ -16,8 +16,6 @@
|
|||||||
<div class='content'
|
<div class='content'
|
||||||
data-server-services-status-url="{{ url_for('server_services_status_api') }}"
|
data-server-services-status-url="{{ url_for('server_services_status_api') }}"
|
||||||
data-get-ip-url="{{ url_for('get_ip_api') }}"
|
data-get-ip-url="{{ url_for('get_ip_api') }}"
|
||||||
data-get-port-url="{{ url_for('get_port_api') }}"
|
|
||||||
data-get-sni-url="{{ url_for('get_sni_api') }}"
|
|
||||||
data-get-all-nodes-url="{{ url_for('get_all_nodes') }}"
|
data-get-all-nodes-url="{{ url_for('get_all_nodes') }}"
|
||||||
data-add-node-url="{{ url_for('add_node') }}"
|
data-add-node-url="{{ url_for('add_node') }}"
|
||||||
data-delete-node-url="{{ url_for('delete_node') }}"
|
data-delete-node-url="{{ url_for('delete_node') }}"
|
||||||
@ -31,16 +29,11 @@
|
|||||||
data-setup-decoy-url="{{ url_for('setup_decoy_api') }}"
|
data-setup-decoy-url="{{ url_for('setup_decoy_api') }}"
|
||||||
data-stop-decoy-url="{{ url_for('stop_decoy_api') }}"
|
data-stop-decoy-url="{{ url_for('stop_decoy_api') }}"
|
||||||
data-get-decoy-status-url="{{ url_for('get_decoy_status_api') }}"
|
data-get-decoy-status-url="{{ url_for('get_decoy_status_api') }}"
|
||||||
data-check-obfs-url="{{ url_for('check_obfs') }}"
|
|
||||||
data-enable-obfs-url="{{ url_for('enable_obfs') }}"
|
|
||||||
data-disable-obfs-url="{{ url_for('disable_obfs') }}"
|
|
||||||
data-telegram-start-url="{{ url_for('telegram_start_api') }}"
|
data-telegram-start-url="{{ url_for('telegram_start_api') }}"
|
||||||
data-telegram-stop-url="{{ url_for('telegram_stop_api') }}"
|
data-telegram-stop-url="{{ url_for('telegram_stop_api') }}"
|
||||||
data-telegram-set-interval-url="{{ url_for('telegram_set_interval_api') }}"
|
data-telegram-set-interval-url="{{ url_for('telegram_set_interval_api') }}"
|
||||||
data-normal-sub-start-url="{{ url_for('normal_sub_start_api') }}"
|
data-normal-sub-start-url="{{ url_for('normal_sub_start_api') }}"
|
||||||
data-normal-sub-stop-url="{{ url_for('normal_sub_stop_api') }}"
|
data-normal-sub-stop-url="{{ url_for('normal_sub_stop_api') }}"
|
||||||
data-set-port-url-template="{{ url_for('set_port_api', port='PORT_PLACEHOLDER') }}"
|
|
||||||
data-set-sni-url-template="{{ url_for('set_sni_api', sni='SNI_PLACEHOLDER') }}"
|
|
||||||
data-edit-ip-url="{{ url_for('edit_ip_api') }}"
|
data-edit-ip-url="{{ url_for('edit_ip_api') }}"
|
||||||
data-backup-url="{{ url_for('backup_api') }}"
|
data-backup-url="{{ url_for('backup_api') }}"
|
||||||
data-restore-url="{{ url_for('restore_api') }}"
|
data-restore-url="{{ url_for('restore_api') }}"
|
||||||
@ -48,7 +41,6 @@
|
|||||||
data-stop-ip-limit-url="{{ url_for('stop_ip_limit_api') }}"
|
data-stop-ip-limit-url="{{ url_for('stop_ip_limit_api') }}"
|
||||||
data-config-ip-limit-url="{{ url_for('config_ip_limit_api') }}"
|
data-config-ip-limit-url="{{ url_for('config_ip_limit_api') }}"
|
||||||
data-status-warp-url="{{ url_for('status_warp') }}"
|
data-status-warp-url="{{ url_for('status_warp') }}"
|
||||||
data-update-geo-url-template="{{ url_for('update_geo', country='COUNTRY_PLACEHOLDER') }}"
|
|
||||||
data-install-warp-url="{{ url_for('install_warp') }}"
|
data-install-warp-url="{{ url_for('install_warp') }}"
|
||||||
data-uninstall-warp-url="{{ url_for('uninstall_warp') }}"
|
data-uninstall-warp-url="{{ url_for('uninstall_warp') }}"
|
||||||
data-configure-warp-url="{{ url_for('configure_warp') }}"
|
data-configure-warp-url="{{ url_for('configure_warp') }}"
|
||||||
@ -69,10 +61,6 @@
|
|||||||
<a class='nav-link' id='telegram-tab' data-toggle='pill' href='#telegram' role='tab'
|
<a class='nav-link' id='telegram-tab' data-toggle='pill' href='#telegram' role='tab'
|
||||||
aria-controls='telegram' aria-selected='true'><i class="fab fa-telegram"></i>
|
aria-controls='telegram' aria-selected='true'><i class="fab fa-telegram"></i>
|
||||||
Telegram Bot</a>
|
Telegram Bot</a>
|
||||||
</li>
|
|
||||||
<li class='nav-item'>
|
|
||||||
<a class='nav-link' id='hysteria-settings-tab' data-toggle='pill' href='#hysteria-settings' role='tab'
|
|
||||||
aria-controls='hysteria-settings' aria-selected='false'><i class="fas fa-cogs"></i> Hysteria Settings</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li class='nav-item'>
|
<li class='nav-item'>
|
||||||
<a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab'
|
<a class='nav-link' id='ip-tab' data-toggle='pill' href='#change_ip' role='tab'
|
||||||
@ -203,93 +191,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hysteria Settings Tab -->
|
|
||||||
<div class='tab-pane fade' id='hysteria-settings' role='tabpanel' aria-labelledby='hysteria-settings-tab'>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-4">
|
|
||||||
<div class="card card-outline card-primary h-100">
|
|
||||||
<div class="card-header"><h3 class="card-title"><i class="fas fa-server"></i> Change Port</h3></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form id="port_form">
|
|
||||||
<div class='form-group'>
|
|
||||||
<label for='hysteria_port'>Port:</label>
|
|
||||||
<input type='text' class='form-control' id='hysteria_port'
|
|
||||||
placeholder='Enter Port'>
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
Please enter a valid port number.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button id="port_change" type='button' class='btn btn-primary'>Save</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-4">
|
|
||||||
<div class="card card-outline card-primary h-100">
|
|
||||||
<div class="card-header"><h3 class="card-title"><i class="fas fa-shield-alt"></i> Change SNI</h3></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form id="sni_form">
|
|
||||||
<div class='form-group'>
|
|
||||||
<label for='sni_domain'>Domain:</label>
|
|
||||||
<input type='text' class='form-control' id='sni_domain'
|
|
||||||
placeholder='Enter Domain'>
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
Please enter a valid domain (without http:// or https://).
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button id="sni_change" type='button' class='btn btn-primary'>Save</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-4">
|
|
||||||
<div class="card card-outline card-secondary h-100">
|
|
||||||
<div class="card-header"><h3 class="card-title"><i class="fas fa-user-secret"></i> OBFS</h3></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<h5>OBFS Status</h5>
|
|
||||||
<div id="obfs_status_container" class="p-3 border rounded">
|
|
||||||
<span id="obfs_status_message">Loading OBFS status...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button id="obfs_enable_btn" type='button' class='btn btn-success' style="display: none;">
|
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
|
||||||
Enable OBFS
|
|
||||||
</button>
|
|
||||||
<button id="obfs_disable_btn" type='button' class='btn btn-danger' style="display: none;">
|
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
|
||||||
Disable OBFS
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-4">
|
|
||||||
<div class="card card-outline card-info h-100">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title"><i class="fas fa-globe-americas"></i> Update GeoIP & GeoSite Files</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>Update GeoIP and GeoSite database files for specific countries. This is useful for routing rules.</p>
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<button id="geo_update_iran" type='button' class='btn btn-secondary mb-2'>
|
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
|
||||||
Update for Iran
|
|
||||||
</button>
|
|
||||||
<button id="geo_update_china" type='button' class='btn btn-secondary mb-2'>
|
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
|
||||||
Update for China
|
|
||||||
</button>
|
|
||||||
<button id="geo_update_russia" type='button' class='btn btn-secondary'>
|
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
|
||||||
Update for Russia
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- IP Management Tab -->
|
<!-- IP Management Tab -->
|
||||||
<div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'>
|
<div class='tab-pane fade' id='change_ip' role='tabpanel' aria-labelledby='ip-tab'>
|
||||||
<div class="card card-outline card-primary">
|
<div class="card card-outline card-primary">
|
||||||
|
|||||||
Reference in New Issue
Block a user