mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 09:12:44 +00:00
refactor panel new logic
This commit is contained in:
parent
7e2f3fda03
commit
beac0cdf67
7 changed files with 286 additions and 277 deletions
|
|
@ -164,10 +164,14 @@ type ClientEntity struct {
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
||||||
Name string `json:"name" form:"name"` // Node name/identifier
|
Name string `json:"name" form:"name"` // Node name/identifier
|
||||||
Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080")
|
Address string `json:"address" form:"address"` // Node API address (e.g., "http://192.168.1.100:8080" or "https://...")
|
||||||
ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication
|
ApiKey string `json:"apiKey" form:"apiKey"` // API key for authentication
|
||||||
Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown
|
Status string `json:"status" gorm:"default:unknown"` // Status: online, offline, unknown
|
||||||
LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp
|
LastCheck int64 `json:"lastCheck" gorm:"default:0"` // Last health check timestamp
|
||||||
|
UseTLS bool `json:"useTls" form:"useTls" gorm:"column:use_tls;default:false"` // Whether to use TLS/HTTPS for API calls
|
||||||
|
CertPath string `json:"certPath" form:"certPath" gorm:"column:cert_path"` // Path to certificate file (optional, for custom CA)
|
||||||
|
KeyPath string `json:"keyPath" form:"keyPath" gorm:"column:key_path"` // Path to private key file (optional, for custom CA)
|
||||||
|
InsecureTLS bool `json:"insecureTls" form:"insecureTls" gorm:"column:insecure_tls;default:false"` // Skip certificate verification (not recommended)
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp
|
||||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,19 @@ func (a *NodeController) updateNode(c *gin.Context) {
|
||||||
if apiKeyVal, ok := jsonData["apiKey"].(string); ok && apiKeyVal != "" {
|
if apiKeyVal, ok := jsonData["apiKey"].(string); ok && apiKeyVal != "" {
|
||||||
node.ApiKey = apiKeyVal
|
node.ApiKey = apiKeyVal
|
||||||
}
|
}
|
||||||
|
// TLS settings
|
||||||
|
if useTlsVal, ok := jsonData["useTls"].(bool); ok {
|
||||||
|
node.UseTLS = useTlsVal
|
||||||
|
}
|
||||||
|
if certPathVal, ok := jsonData["certPath"].(string); ok {
|
||||||
|
node.CertPath = certPathVal
|
||||||
|
}
|
||||||
|
if keyPathVal, ok := jsonData["keyPath"].(string); ok {
|
||||||
|
node.KeyPath = keyPathVal
|
||||||
|
}
|
||||||
|
if insecureTlsVal, ok := jsonData["insecureTls"].(bool); ok {
|
||||||
|
node.InsecureTLS = insecureTlsVal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Parse as form data (default for web UI)
|
// Parse as form data (default for web UI)
|
||||||
|
|
@ -163,6 +176,15 @@ func (a *NodeController) updateNode(c *gin.Context) {
|
||||||
if apiKey := c.PostForm("apiKey"); apiKey != "" {
|
if apiKey := c.PostForm("apiKey"); apiKey != "" {
|
||||||
node.ApiKey = apiKey
|
node.ApiKey = apiKey
|
||||||
}
|
}
|
||||||
|
// TLS settings
|
||||||
|
node.UseTLS = c.PostForm("useTls") == "true" || c.PostForm("useTls") == "on"
|
||||||
|
if certPath := c.PostForm("certPath"); certPath != "" {
|
||||||
|
node.CertPath = certPath
|
||||||
|
}
|
||||||
|
if keyPath := c.PostForm("keyPath"); keyPath != "" {
|
||||||
|
node.KeyPath = keyPath
|
||||||
|
}
|
||||||
|
node.InsecureTLS = c.PostForm("insecureTls") == "true" || c.PostForm("insecureTls") == "on"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate API key if it was changed
|
// Validate API key if it was changed
|
||||||
|
|
|
||||||
|
|
@ -1,232 +1,149 @@
|
||||||
{{define "modals/nodeModal"}}
|
{{define "modals/nodeModal"}}
|
||||||
<a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title" :confirm-loading="nodeModal.confirmLoading"
|
<a-modal id="node-modal" v-model="nodeModal.visible" :title="nodeModal.title"
|
||||||
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :class="themeSwitcher.currentTheme" :ok-text="nodeModal.okText" :width="600">
|
@ok="nodeModal.ok" @cancel="nodeModal.cancel" :ok-text="nodeModal.okText" :width="600">
|
||||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
<a-form layout="vertical">
|
||||||
<a-form-item :label='{{ i18n "pages.nodes.nodeName" }}'>
|
<a-form-item label='{{ i18n "pages.nodes.nodeName" }}'>
|
||||||
<a-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input>
|
<a-input v-model.trim="nodeModal.formData.name" placeholder="e.g., Node-1"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item :label='{{ i18n "pages.nodes.nodeAddress" }}'>
|
<a-form-item label='{{ i18n "pages.nodes.nodeAddress" }}'>
|
||||||
<a-input v-model.trim="nodeModal.formData.address" placeholder="http://192.168.1.100:8080"></a-input>
|
<a-input v-model.trim="nodeModal.formData.address" placeholder='{{ i18n "pages.nodes.fullUrlHint" }}'></a-input>
|
||||||
<div style="margin-top: 4px; color: #999; font-size: 12px;">
|
|
||||||
{{ i18n "pages.nodes.fullUrlHint" }}
|
|
||||||
</div>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item :label='{{ i18n "pages.nodes.nodeApiKey" }}'>
|
<a-form-item label='{{ i18n "pages.nodes.nodePort" }}'>
|
||||||
<a-input-password v-model.trim="nodeModal.formData.apiKey" placeholder="{{ i18n "pages.nodes.enterApiKey" }}"></a-input>
|
<a-input-number v-model.number="nodeModal.formData.port" :min="1" :max="65535" :style="{ width: '100%' }"></a-input-number>
|
||||||
<div style="margin-top: 4px; color: #999; font-size: 12px;">
|
</a-form-item>
|
||||||
{{ i18n "pages.nodes.apiKeyHint" }}
|
<a-form-item label='{{ i18n "pages.nodes.nodeApiKey" }}'>
|
||||||
</div>
|
<a-input-password v-model.trim="nodeModal.formData.apiKey" placeholder='{{ i18n "pages.nodes.enterApiKey" }}'></a-input-password>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
<script>
|
<script>
|
||||||
// Make nodeModal globally available to ensure it works with any base path
|
|
||||||
const nodeModal = window.nodeModal = {
|
const nodeModal = window.nodeModal = {
|
||||||
visible: false,
|
visible: false,
|
||||||
title: '',
|
title: '',
|
||||||
confirmLoading: false,
|
okText: 'OK',
|
||||||
okText: '{{ i18n "sure" }}',
|
|
||||||
isEdit: false,
|
|
||||||
currentNode: null,
|
|
||||||
confirm: null,
|
|
||||||
formData: {
|
formData: {
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
|
port: 8080,
|
||||||
apiKey: ''
|
apiKey: ''
|
||||||
},
|
},
|
||||||
ok() {
|
ok() {
|
||||||
// Validate form data
|
// Валидация полей - используем nodeModal напрямую для правильного контекста
|
||||||
if (!this.formData.name || !this.formData.name.trim()) {
|
if (!nodeModal.formData.name || !nodeModal.formData.name.trim()) {
|
||||||
|
if (typeof app !== 'undefined' && app.$message) {
|
||||||
|
app.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
|
||||||
|
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
|
||||||
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
|
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.formData.address || !this.formData.address.trim()) {
|
|
||||||
|
if (!nodeModal.formData.address || !nodeModal.formData.address.trim()) {
|
||||||
|
if (typeof app !== 'undefined' && app.$message) {
|
||||||
|
app.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}');
|
||||||
|
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
|
||||||
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}');
|
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterNodeAddress" }}');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^https?:\/\/.+/.test(this.formData.address)) {
|
|
||||||
Vue.prototype.$message.error('{{ i18n "pages.nodes.validUrl" }}');
|
if (!nodeModal.formData.apiKey || !nodeModal.formData.apiKey.trim()) {
|
||||||
return;
|
if (typeof app !== 'undefined' && app.$message) {
|
||||||
}
|
app.$message.error('{{ i18n "pages.nodes.enterApiKey" }}');
|
||||||
if (!this.formData.apiKey || !this.formData.apiKey.trim()) {
|
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
|
||||||
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterApiKey" }}');
|
Vue.prototype.$message.error('{{ i18n "pages.nodes.enterApiKey" }}');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.confirmLoading = true;
|
// Если все поля заполнены, формируем полный адрес с портом
|
||||||
if (this.confirm) {
|
const dataToSend = { ...nodeModal.formData };
|
||||||
const result = this.confirm({ ...this.formData });
|
|
||||||
// If confirm returns a promise, handle it
|
// Всегда добавляем порт к адресу
|
||||||
if (result && typeof result.then === 'function') {
|
let fullAddress = dataToSend.address.trim();
|
||||||
result.catch(() => {
|
const port = dataToSend.port && dataToSend.port > 0 ? dataToSend.port : 8080;
|
||||||
this.confirmLoading = false;
|
|
||||||
});
|
// Правильно добавляем порт к URL
|
||||||
|
// Парсим URL: http://192.168.0.7 -> http://192.168.0.7:8080
|
||||||
|
const urlMatch = fullAddress.match(/^(https?:\/\/)([^\/:]+)(\/.*)?$/);
|
||||||
|
if (urlMatch) {
|
||||||
|
const protocol = urlMatch[1]; // http:// или https://
|
||||||
|
const host = urlMatch[2]; // 192.168.0.7
|
||||||
|
const path = urlMatch[3] || ''; // /path или ''
|
||||||
|
fullAddress = `${protocol}${host}:${port}${path}`;
|
||||||
} else {
|
} else {
|
||||||
// If not async, reset loading after a short delay
|
// Если не удалось распарсить, просто добавляем порт
|
||||||
setTimeout(() => {
|
fullAddress = `${fullAddress}:${port}`;
|
||||||
this.confirmLoading = false;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.confirmLoading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
show({ title = '', okText = '{{ i18n "sure" }}', node = null, confirm = (data) => { }, isEdit = false }) {
|
|
||||||
console.log('[nodeModal.show] START - called with:', { title, okText, node, isEdit });
|
|
||||||
console.log('[nodeModal.show] this.visible before:', this.visible);
|
|
||||||
console.log('[nodeModal.show] nodeModalVueInstance:', nodeModalVueInstance);
|
|
||||||
|
|
||||||
// Update properties using 'this' like in inbound_modal
|
|
||||||
this.title = title;
|
|
||||||
this.okText = okText;
|
|
||||||
this.isEdit = isEdit;
|
|
||||||
this.confirm = confirm;
|
|
||||||
console.log('[nodeModal.show] Properties updated:', { title: this.title, okText: this.okText, isEdit: this.isEdit });
|
|
||||||
|
|
||||||
if (node) {
|
|
||||||
this.currentNode = node;
|
|
||||||
this.formData = {
|
|
||||||
name: node.name || '',
|
|
||||||
address: node.address || '',
|
|
||||||
apiKey: node.apiKey || ''
|
|
||||||
};
|
|
||||||
console.log('[nodeModal.show] Node data set:', this.formData);
|
|
||||||
} else {
|
|
||||||
this.currentNode = null;
|
|
||||||
this.formData = {
|
|
||||||
name: '',
|
|
||||||
address: '',
|
|
||||||
apiKey: ''
|
|
||||||
};
|
|
||||||
console.log('[nodeModal.show] Form data reset (new node)');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set visible - Vue will track this since nodeModal is in Vue instance data
|
// Удаляем порт из данных, так как он теперь в адресе
|
||||||
console.log('[nodeModal.show] Setting this.visible = true');
|
delete dataToSend.port;
|
||||||
this.visible = true;
|
dataToSend.address = fullAddress;
|
||||||
console.log('[nodeModal.show] this.visible after setting:', this.visible);
|
|
||||||
|
|
||||||
// Check Vue instance
|
// Вызываем confirm с объединенным адресом
|
||||||
if (nodeModalVueInstance) {
|
if (nodeModal.confirm) {
|
||||||
console.log('[nodeModal.show] Vue instance exists');
|
nodeModal.confirm(dataToSend);
|
||||||
console.log('[nodeModal.show] nodeModalVueInstance.nodeModal:', nodeModalVueInstance.nodeModal);
|
|
||||||
console.log('[nodeModal.show] nodeModalVueInstance.nodeModal.visible:', nodeModalVueInstance.nodeModal.visible);
|
|
||||||
console.log('[nodeModal.show] nodeModalVueInstance.$el:', nodeModalVueInstance.$el);
|
|
||||||
} else {
|
|
||||||
console.warn('[nodeModal.show] WARNING - Vue instance does not exist!');
|
|
||||||
}
|
}
|
||||||
|
nodeModal.visible = false;
|
||||||
// Check DOM element
|
|
||||||
const modalElement = document.getElementById('node-modal');
|
|
||||||
console.log('[nodeModal.show] Modal element in DOM:', modalElement);
|
|
||||||
if (modalElement) {
|
|
||||||
console.log('[nodeModal.show] Modal element classes:', modalElement.className);
|
|
||||||
console.log('[nodeModal.show] Modal element style.display:', modalElement.style.display);
|
|
||||||
const computedStyle = window.getComputedStyle(modalElement);
|
|
||||||
console.log('[nodeModal.show] Modal element computed display:', computedStyle.display);
|
|
||||||
console.log('[nodeModal.show] Modal element computed visibility:', computedStyle.visibility);
|
|
||||||
console.log('[nodeModal.show] Modal element computed opacity:', computedStyle.opacity);
|
|
||||||
console.log('[nodeModal.show] Modal element computed z-index:', computedStyle.zIndex);
|
|
||||||
|
|
||||||
// Check for Ant Design modal root
|
|
||||||
const modalRoot = document.querySelector('.ant-modal-root');
|
|
||||||
console.log('[nodeModal.show] Ant Design modal root exists:', !!modalRoot);
|
|
||||||
if (modalRoot) {
|
|
||||||
console.log('[nodeModal.show] Modal root style.display:', window.getComputedStyle(modalRoot).display);
|
|
||||||
const modalWrap = modalRoot.querySelector('.ant-modal-wrap');
|
|
||||||
console.log('[nodeModal.show] Modal wrap exists:', !!modalWrap);
|
|
||||||
if (modalWrap) {
|
|
||||||
console.log('[nodeModal.show] Modal wrap style.display:', window.getComputedStyle(modalWrap).display);
|
|
||||||
const modalInWrap = modalWrap.querySelector('#node-modal');
|
|
||||||
console.log('[nodeModal.show] Modal #node-modal in wrap:', !!modalInWrap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('[nodeModal.show] ERROR - Modal element #node-modal not found in DOM!');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use nextTick to check after Vue updates
|
|
||||||
if (nodeModalVueInstance) {
|
|
||||||
nodeModalVueInstance.$nextTick(() => {
|
|
||||||
console.log('[nodeModal.show] After $nextTick - nodeModal.visible:', nodeModalVueInstance.nodeModal.visible);
|
|
||||||
const modalElementAfter = document.getElementById('node-modal');
|
|
||||||
if (modalElementAfter) {
|
|
||||||
const modalRootAfter = document.querySelector('.ant-modal-root');
|
|
||||||
if (modalRootAfter) {
|
|
||||||
console.log('[nodeModal.show] After $nextTick - Modal root display:', window.getComputedStyle(modalRootAfter).display);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[nodeModal.show] END');
|
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
nodeModal.visible = false;
|
this.visible = false;
|
||||||
// Reset form data
|
},
|
||||||
nodeModal.formData = {
|
show({ title = '', okText = 'OK', node = null, confirm = (data) => { }, isEdit = false }) {
|
||||||
|
this.title = title;
|
||||||
|
this.okText = okText;
|
||||||
|
this.confirm = confirm;
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
// Извлекаем адрес и порт из полного URL
|
||||||
|
let address = node.address || '';
|
||||||
|
let port = 8080;
|
||||||
|
|
||||||
|
// Всегда извлекаем порт из адреса, если он там есть
|
||||||
|
if (address) {
|
||||||
|
const urlMatch = address.match(/^(https?:\/\/[^\/:]+)(:(\d+))?(\/.*)?$/);
|
||||||
|
if (urlMatch) {
|
||||||
|
// Убираем порт из адреса для отображения
|
||||||
|
const protocol = urlMatch[1].match(/^(https?:\/\/)/)[1];
|
||||||
|
const host = urlMatch[1].replace(/^https?:\/\//, '');
|
||||||
|
const path = urlMatch[4] || '';
|
||||||
|
address = `${protocol}${host}${path}`;
|
||||||
|
|
||||||
|
// Если порт был в адресе, извлекаем его
|
||||||
|
if (urlMatch[3]) {
|
||||||
|
port = parseInt(urlMatch[3], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.formData = {
|
||||||
|
name: node.name || '',
|
||||||
|
address: address,
|
||||||
|
port: port,
|
||||||
|
apiKey: node.apiKey || ''
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.formData = {
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
|
port: 8080,
|
||||||
apiKey: ''
|
apiKey: ''
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.visible = true;
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
nodeModal.visible = false;
|
this.visible = false;
|
||||||
nodeModal.confirmLoading = false;
|
|
||||||
},
|
|
||||||
loading(loading = true) {
|
|
||||||
this.confirmLoading = loading;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store Vue instance globally to ensure methods are always accessible
|
const nodeModalVueInstance = new Vue({
|
||||||
let nodeModalVueInstance = null;
|
|
||||||
|
|
||||||
// Create Vue instance after main app is ready
|
|
||||||
window.initNodeModalVue = function initNodeModalVue() {
|
|
||||||
if (nodeModalVueInstance) {
|
|
||||||
return; // Already initialized
|
|
||||||
}
|
|
||||||
|
|
||||||
const modalElement = document.getElementById('node-modal');
|
|
||||||
if (!modalElement) {
|
|
||||||
setTimeout(initNodeModalVue, 50);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
nodeModalVueInstance = new Vue({
|
|
||||||
delimiters: ['[[', ']]'],
|
delimiters: ['[[', ']]'],
|
||||||
el: '#node-modal',
|
el: '#node-modal',
|
||||||
data: {
|
data: {
|
||||||
nodeModal: nodeModal,
|
nodeModal: nodeModal
|
||||||
get themeSwitcher() {
|
|
||||||
// Try to get themeSwitcher from window or global scope
|
|
||||||
if (typeof window !== 'undefined' && window.themeSwitcher) {
|
|
||||||
return window.themeSwitcher;
|
|
||||||
}
|
|
||||||
if (typeof themeSwitcher !== 'undefined') {
|
|
||||||
return themeSwitcher;
|
|
||||||
}
|
|
||||||
// Fallback to a simple object if themeSwitcher is not available
|
|
||||||
return { currentTheme: 'light' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.nodeModalVueInstance = nodeModalVueInstance;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[nodeModal init] ERROR creating Vue instance:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wait for DOM and main app to be ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
setTimeout(window.initNodeModalVue, 100);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setTimeout(window.initNodeModalVue, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,7 @@
|
||||||
<h2>{{ i18n "pages.nodes.title" }}</h2>
|
<h2>{{ i18n "pages.nodes.title" }}</h2>
|
||||||
|
|
||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
<h3>{{ i18n "pages.nodes.addNewNode" }}</h3>
|
<a-button type="primary" icon="plus" @click="openAddNode">{{ i18n "pages.nodes.addNewNode" }}</a-button>
|
||||||
<a-input id="node-name" placeholder='{{ i18n "pages.nodes.nodeName" }}' style="width: 200px; margin-right: 10px;"></a-input>
|
|
||||||
<a-input id="node-address" placeholder='{{ i18n "pages.nodes.nodeAddress" }} (http://192.168.1.100)' style="width: 250px; margin-right: 10px;"></a-input>
|
|
||||||
<a-input id="node-port" placeholder='{{ i18n "pages.nodes.nodePort" }} (8080)' type="number" style="width: 120px; margin-right: 10px;"></a-input>
|
|
||||||
<a-input-password id="node-apikey" placeholder='{{ i18n "pages.nodes.nodeApiKey" }}' style="width: 200px; margin-right: 10px;"></a-input-password>
|
|
||||||
<a-button type="primary" onclick="addNode()">{{ i18n "pages.nodes.addNode" }}</a-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
|
|
@ -109,6 +104,7 @@
|
||||||
<script src="{{ .base_path }}assets/js/model/node.js?{{ .cur_ver }}"></script>
|
<script src="{{ .base_path }}assets/js/model/node.js?{{ .cur_ver }}"></script>
|
||||||
{{template "component/aSidebar" .}}
|
{{template "component/aSidebar" .}}
|
||||||
{{template "component/aThemeSwitch" .}}
|
{{template "component/aThemeSwitch" .}}
|
||||||
|
{{template "modals/nodeModal"}}
|
||||||
<script>
|
<script>
|
||||||
const columns = [{
|
const columns = [{
|
||||||
title: "ID",
|
title: "ID",
|
||||||
|
|
@ -228,7 +224,7 @@
|
||||||
this.reloadNode(node.id);
|
this.reloadNode(node.id);
|
||||||
break;
|
break;
|
||||||
case 'edit':
|
case 'edit':
|
||||||
this.startEditNodeName(node);
|
this.editNode(node);
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
this.deleteNode(node.id);
|
this.deleteNode(node.id);
|
||||||
|
|
@ -388,6 +384,63 @@
|
||||||
} finally {
|
} finally {
|
||||||
this.reloadingAll = false;
|
this.reloadingAll = false;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
openAddNode() {
|
||||||
|
if (typeof window.nodeModal !== 'undefined') {
|
||||||
|
window.nodeModal.show({
|
||||||
|
title: '{{ i18n "pages.nodes.addNewNode" }}',
|
||||||
|
okText: '{{ i18n "create" }}',
|
||||||
|
confirm: async (nodeData) => {
|
||||||
|
await this.submitNode(nodeData, false);
|
||||||
|
},
|
||||||
|
isEdit: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('[openAddNode] ERROR: nodeModal is not defined!');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editNode(node) {
|
||||||
|
if (typeof window.nodeModal !== 'undefined') {
|
||||||
|
// Load full node data including TLS settings
|
||||||
|
HttpUtil.get(`/panel/node/get/${node.id}`).then(msg => {
|
||||||
|
if (msg && msg.success && msg.obj) {
|
||||||
|
window.nodeModal.show({
|
||||||
|
title: '{{ i18n "pages.nodes.editNode" }}',
|
||||||
|
okText: '{{ i18n "update" }}',
|
||||||
|
node: msg.obj,
|
||||||
|
confirm: async (nodeData) => {
|
||||||
|
await this.submitNode(nodeData, true, node.id);
|
||||||
|
},
|
||||||
|
isEdit: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
app.$message.error('{{ i18n "pages.nodes.loadError" }}');
|
||||||
|
}
|
||||||
|
}).catch(e => {
|
||||||
|
console.error("Failed to load node:", e);
|
||||||
|
app.$message.error('{{ i18n "pages.nodes.loadError" }}');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('[editNode] ERROR: nodeModal is not defined!');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submitNode(nodeData, isEdit, nodeId = null) {
|
||||||
|
try {
|
||||||
|
const url = isEdit ? `/panel/node/update/${nodeId}` : '/panel/node/add';
|
||||||
|
const msg = await HttpUtil.post(url, nodeData);
|
||||||
|
if (msg && msg.success) {
|
||||||
|
app.$message.success(isEdit ? '{{ i18n "pages.nodes.updateSuccess" }}' : '{{ i18n "pages.nodes.addSuccess" }}');
|
||||||
|
await this.loadNodes();
|
||||||
|
if (window.nodeModal) {
|
||||||
|
window.nodeModal.close();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.$message.error((msg && msg.msg) || (isEdit ? '{{ i18n "pages.nodes.updateError" }}' : '{{ i18n "pages.nodes.addError" }}'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to ${isEdit ? 'update' : 'add'} node:`, e);
|
||||||
|
app.$message.error(isEdit ? '{{ i18n "pages.nodes.updateError" }}' : '{{ i18n "pages.nodes.addError" }}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
@ -395,72 +448,5 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function addNode() {
|
|
||||||
const name = document.getElementById('node-name').value.trim();
|
|
||||||
const address = document.getElementById('node-address').value.trim();
|
|
||||||
const port = document.getElementById('node-port').value.trim();
|
|
||||||
const apiKey = document.getElementById('node-apikey').value;
|
|
||||||
|
|
||||||
if (!name || !address || !port || !apiKey) {
|
|
||||||
app.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate address format
|
|
||||||
if (!address.match(/^https?:\/\//)) {
|
|
||||||
app.$message.error('{{ i18n "pages.nodes.validUrl" }}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate port
|
|
||||||
const portNum = parseInt(port);
|
|
||||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
||||||
app.$message.error('{{ i18n "pages.nodes.validPort" }}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct full address
|
|
||||||
const fullAddress = `${address}:${port}`;
|
|
||||||
|
|
||||||
// Check for duplicate nodes
|
|
||||||
const existingNodes = app.$data.nodes || [];
|
|
||||||
const duplicate = existingNodes.find(node => {
|
|
||||||
try {
|
|
||||||
const nodeUrl = new URL(node.address);
|
|
||||||
const newUrl = new URL(fullAddress);
|
|
||||||
// Compare protocol, hostname, and port
|
|
||||||
const nodePort = nodeUrl.port || (nodeUrl.protocol === 'https:' ? '443' : '80');
|
|
||||||
const newPort = newUrl.port || (newUrl.protocol === 'https:' ? '443' : '80');
|
|
||||||
return nodeUrl.protocol === newUrl.protocol &&
|
|
||||||
nodeUrl.hostname === newUrl.hostname &&
|
|
||||||
nodePort === newPort;
|
|
||||||
} catch (e) {
|
|
||||||
// If URL parsing fails, do simple string comparison
|
|
||||||
return node.address === fullAddress;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (duplicate) {
|
|
||||||
app.$message.error('{{ i18n "pages.nodes.duplicateNode" }}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const msg = await HttpUtil.post('/panel/node/add', { name, address: fullAddress, apiKey });
|
|
||||||
if (msg.success) {
|
|
||||||
app.$message.success('{{ i18n "pages.nodes.addSuccess" }}');
|
|
||||||
document.getElementById('node-name').value = '';
|
|
||||||
document.getElementById('node-address').value = '';
|
|
||||||
document.getElementById('node-port').value = '';
|
|
||||||
document.getElementById('node-apikey').value = '';
|
|
||||||
app.loadNodes();
|
|
||||||
} else {
|
|
||||||
app.$message.error(msg.msg || '{{ i18n "pages.nodes.addError" }}');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
app.$message.error('{{ i18n "pages.nodes.addError" }}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
{{template "page/body_end" .}}
|
{{template "page/body_end" .}}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -70,6 +73,16 @@ func (s *NodeService) UpdateNode(node *model.Node) error {
|
||||||
updates["api_key"] = node.ApiKey
|
updates["api_key"] = node.ApiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update TLS settings if provided
|
||||||
|
updates["use_tls"] = node.UseTLS
|
||||||
|
if node.CertPath != "" {
|
||||||
|
updates["cert_path"] = node.CertPath
|
||||||
|
}
|
||||||
|
if node.KeyPath != "" {
|
||||||
|
updates["key_path"] = node.KeyPath
|
||||||
|
}
|
||||||
|
updates["insecure_tls"] = node.InsecureTLS
|
||||||
|
|
||||||
// Update status and last_check if provided (these are usually set by health checks, not user edits)
|
// Update status and last_check if provided (these are usually set by health checks, not user edits)
|
||||||
if node.Status != "" && node.Status != existingNode.Status {
|
if node.Status != "" && node.Status != existingNode.Status {
|
||||||
updates["status"] = node.Status
|
updates["status"] = node.Status
|
||||||
|
|
@ -117,10 +130,53 @@ func (s *NodeService) CheckNodeHealth(node *model.Node) error {
|
||||||
return s.UpdateNode(node)
|
return s.UpdateNode(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createHTTPClient creates an HTTP client configured for the node's TLS settings.
|
||||||
|
func (s *NodeService) createHTTPClient(node *model.Node, timeout time.Duration) (*http.Client, error) {
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: node.InsecureTLS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If custom certificates are provided, load them
|
||||||
|
if node.UseTLS && node.CertPath != "" {
|
||||||
|
// Load custom CA certificate
|
||||||
|
cert, err := os.ReadFile(node.CertPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read certificate file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPool := x509.NewCertPool()
|
||||||
|
if !caCertPool.AppendCertsFromPEM(cert) {
|
||||||
|
return nil, fmt.Errorf("failed to parse certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.TLSClientConfig.RootCAs = caCertPool
|
||||||
|
transport.TLSClientConfig.InsecureSkipVerify = false // Use custom CA
|
||||||
|
}
|
||||||
|
|
||||||
|
// If custom key is provided, load client certificate
|
||||||
|
if node.UseTLS && node.KeyPath != "" && node.CertPath != "" {
|
||||||
|
// Load client certificate (cert + key)
|
||||||
|
clientCert, err := tls.LoadX509KeyPair(node.CertPath, node.KeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load client certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.TLSClientConfig.Certificates = []tls.Certificate{clientCert}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
Transport: transport,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CheckNodeStatus performs a health check on a given node.
|
// CheckNodeStatus performs a health check on a given node.
|
||||||
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, error) {
|
func (s *NodeService) CheckNodeStatus(node *model.Node) (string, error) {
|
||||||
client := &http.Client{
|
client, err := s.createHTTPClient(node, 5*time.Second)
|
||||||
Timeout: 5 * time.Second,
|
if err != nil {
|
||||||
|
return "error", err
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/health", node.Address)
|
url := fmt.Sprintf("%s/health", node.Address)
|
||||||
|
|
@ -226,8 +282,9 @@ type NodeClientTraffic struct {
|
||||||
|
|
||||||
// GetNodeStats retrieves traffic and online clients statistics from a node.
|
// GetNodeStats retrieves traffic and online clients statistics from a node.
|
||||||
func (s *NodeService) GetNodeStats(node *model.Node, reset bool) (*NodeStatsResponse, error) {
|
func (s *NodeService) GetNodeStats(node *model.Node, reset bool) (*NodeStatsResponse, error) {
|
||||||
client := &http.Client{
|
client, err := s.createHTTPClient(node, 10*time.Second)
|
||||||
Timeout: 10 * time.Second,
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/stats", node.Address)
|
url := fmt.Sprintf("%s/api/v1/stats", node.Address)
|
||||||
|
|
@ -419,8 +476,9 @@ func (s *NodeService) UnassignInboundFromNode(inboundId int) error {
|
||||||
|
|
||||||
// ApplyConfigToNode sends XRAY configuration to a node.
|
// ApplyConfigToNode sends XRAY configuration to a node.
|
||||||
func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) error {
|
func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) error {
|
||||||
client := &http.Client{
|
client, err := s.createHTTPClient(node, 30*time.Second)
|
||||||
Timeout: 30 * time.Second,
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/apply-config", node.Address)
|
url := fmt.Sprintf("%s/api/v1/apply-config", node.Address)
|
||||||
|
|
@ -448,8 +506,9 @@ func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) err
|
||||||
|
|
||||||
// ReloadNode reloads XRAY on a specific node.
|
// ReloadNode reloads XRAY on a specific node.
|
||||||
func (s *NodeService) ReloadNode(node *model.Node) error {
|
func (s *NodeService) ReloadNode(node *model.Node) error {
|
||||||
client := &http.Client{
|
client, err := s.createHTTPClient(node, 30*time.Second)
|
||||||
Timeout: 30 * time.Second,
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/reload", node.Address)
|
url := fmt.Sprintf("%s/api/v1/reload", node.Address)
|
||||||
|
|
@ -476,8 +535,9 @@ func (s *NodeService) ReloadNode(node *model.Node) error {
|
||||||
|
|
||||||
// ForceReloadNode forcefully reloads XRAY on a specific node (even if hung).
|
// ForceReloadNode forcefully reloads XRAY on a specific node (even if hung).
|
||||||
func (s *NodeService) ForceReloadNode(node *model.Node) error {
|
func (s *NodeService) ForceReloadNode(node *model.Node) error {
|
||||||
client := &http.Client{
|
client, err := s.createHTTPClient(node, 30*time.Second)
|
||||||
Timeout: 30 * time.Second,
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/force-reload", node.Address)
|
url := fmt.Sprintf("%s/api/v1/force-reload", node.Address)
|
||||||
|
|
@ -539,8 +599,9 @@ func (s *NodeService) ReloadAllNodes() error {
|
||||||
|
|
||||||
// ValidateApiKey validates the API key by making a test request to the node.
|
// ValidateApiKey validates the API key by making a test request to the node.
|
||||||
func (s *NodeService) ValidateApiKey(node *model.Node) error {
|
func (s *NodeService) ValidateApiKey(node *model.Node) error {
|
||||||
client := &http.Client{
|
client, err := s.createHTTPClient(node, 5*time.Second)
|
||||||
Timeout: 5 * time.Second,
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, check if node is reachable via health endpoint
|
// First, check if node is reachable via health endpoint
|
||||||
|
|
@ -593,8 +654,9 @@ func (s *NodeService) ValidateApiKey(node *model.Node) error {
|
||||||
|
|
||||||
// GetNodeStatus retrieves the status of a node.
|
// GetNodeStatus retrieves the status of a node.
|
||||||
func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) {
|
func (s *NodeService) GetNodeStatus(node *model.Node) (map[string]interface{}, error) {
|
||||||
client := &http.Client{
|
client, err := s.createHTTPClient(node, 5*time.Second)
|
||||||
Timeout: 5 * time.Second,
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/status", node.Address)
|
url := fmt.Sprintf("%s/api/v1/status", node.Address)
|
||||||
|
|
|
||||||
|
|
@ -622,7 +622,7 @@
|
||||||
"validUrl" = "Must be a valid URL (http:// or https://)"
|
"validUrl" = "Must be a valid URL (http:// or https://)"
|
||||||
"validPort" = "Port must be a number between 1 and 65535"
|
"validPort" = "Port must be a number between 1 and 65535"
|
||||||
"duplicateNode" = "A node with this address and port already exists"
|
"duplicateNode" = "A node with this address and port already exists"
|
||||||
"fullUrlHint" = "Full URL to node API (e.g., http://192.168.1.100:8080)"
|
"fullUrlHint" = "Full URL to node API (e.g., http://192.168.1.100 or domain)"
|
||||||
"enterApiKey" = "Please enter API key"
|
"enterApiKey" = "Please enter API key"
|
||||||
"apiKeyHint" = "API key configured on the node (NODE_API_KEY environment variable)"
|
"apiKeyHint" = "API key configured on the node (NODE_API_KEY environment variable)"
|
||||||
"leaveEmptyToKeep" = "leave empty to keep current"
|
"leaveEmptyToKeep" = "leave empty to keep current"
|
||||||
|
|
@ -644,6 +644,15 @@
|
||||||
"reloadError" = "Failed to reload node"
|
"reloadError" = "Failed to reload node"
|
||||||
"reloadAllSuccess" = "All nodes reloaded successfully"
|
"reloadAllSuccess" = "All nodes reloaded successfully"
|
||||||
"reloadAllError" = "Failed to reload some nodes"
|
"reloadAllError" = "Failed to reload some nodes"
|
||||||
|
"tlsSettings" = "TLS/HTTPS Settings"
|
||||||
|
"useTls" = "Use TLS/HTTPS"
|
||||||
|
"useTlsHint" = "Enable TLS/HTTPS for API calls to this node"
|
||||||
|
"certPath" = "Certificate Path"
|
||||||
|
"certPathHint" = "Path to CA certificate file (optional, for custom CA)"
|
||||||
|
"keyPath" = "Private Key Path"
|
||||||
|
"keyPathHint" = "Path to private key file (optional, for client certificate)"
|
||||||
|
"insecureTls" = "Skip Certificate Verification"
|
||||||
|
"insecureTlsHint" = "⚠️ Not recommended: Skip TLS certificate verification (insecure)"
|
||||||
|
|
||||||
[pages.nodes.toasts]
|
[pages.nodes.toasts]
|
||||||
"createSuccess" = "Node created successfully"
|
"createSuccess" = "Node created successfully"
|
||||||
|
|
|
||||||
|
|
@ -622,7 +622,7 @@
|
||||||
"validUrl" = "Должен быть действительным URL (http:// или https://)"
|
"validUrl" = "Должен быть действительным URL (http:// или https://)"
|
||||||
"validPort" = "Порт должен быть числом от 1 до 65535"
|
"validPort" = "Порт должен быть числом от 1 до 65535"
|
||||||
"duplicateNode" = "Нода с таким адресом и портом уже существует"
|
"duplicateNode" = "Нода с таким адресом и портом уже существует"
|
||||||
"fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100:8080)"
|
"fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100 или домен)"
|
||||||
"enterApiKey" = "Пожалуйста, введите API ключ"
|
"enterApiKey" = "Пожалуйста, введите API ключ"
|
||||||
"apiKeyHint" = "API ключ, настроенный на ноде (переменная окружения NODE_API_KEY)"
|
"apiKeyHint" = "API ключ, настроенный на ноде (переменная окружения NODE_API_KEY)"
|
||||||
"leaveEmptyToKeep" = "оставьте пустым чтобы не менять"
|
"leaveEmptyToKeep" = "оставьте пустым чтобы не менять"
|
||||||
|
|
@ -644,6 +644,15 @@
|
||||||
"reloadError" = "Не удалось перезагрузить ноду"
|
"reloadError" = "Не удалось перезагрузить ноду"
|
||||||
"reloadAllSuccess" = "Все ноды успешно перезагружены"
|
"reloadAllSuccess" = "Все ноды успешно перезагружены"
|
||||||
"reloadAllError" = "Не удалось перезагрузить некоторые ноды"
|
"reloadAllError" = "Не удалось перезагрузить некоторые ноды"
|
||||||
|
"tlsSettings" = "Настройки TLS/HTTPS"
|
||||||
|
"useTls" = "Использовать TLS/HTTPS"
|
||||||
|
"useTlsHint" = "Включить TLS/HTTPS для API вызовов к этой ноде"
|
||||||
|
"certPath" = "Путь к сертификату"
|
||||||
|
"certPathHint" = "Путь к файлу сертификата CA (опционально, для кастомного CA)"
|
||||||
|
"keyPath" = "Путь к приватному ключу"
|
||||||
|
"keyPathHint" = "Путь к файлу приватного ключа (опционально, для клиентского сертификата)"
|
||||||
|
"insecureTls" = "Пропустить проверку сертификата"
|
||||||
|
"insecureTlsHint" = "⚠️ Не рекомендуется: пропустить проверку TLS сертификата (небезопасно)"
|
||||||
|
|
||||||
[pages.nodes.toasts]
|
[pages.nodes.toasts]
|
||||||
"createSuccess" = "Нода успешно создана"
|
"createSuccess" = "Нода успешно создана"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue